@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +860 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +504 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/debug.js +45 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +213 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +840 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/sampling.js +19 -0
- package/src/segment.js +388 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +176 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +150 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +230 -0
- package/src/shape.js +120 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +164 -0
- package/src/transport.js +451 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +165 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +713 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +617 -0
- package/types/segment.d.ts +68 -0
package/src/preview.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview (3.15): a trader opens the site with `?hp_preview=<token>`, a token the console made to
|
|
3
|
+
* force one arm of one slot. The SDK keeps it in a session cookie, so every server render of the
|
|
4
|
+
* visit can forward it to `/v1/assignments`, which answers the forced arm and logs nothing.
|
|
5
|
+
* `?hp_preview=` with no value ends the preview.
|
|
6
|
+
*
|
|
7
|
+
* While a preview is on, the browser reports no exposure and no slot engagement: the trader is not
|
|
8
|
+
* a shopper, so their views must neither train a slot nor count in its readout. Everything else is
|
|
9
|
+
* sent as usual. The token is checked by the server, never here; one that has expired forces
|
|
10
|
+
* nothing there, and still silences these two events here until the browser session ends.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getCookie } from './context.js';
|
|
14
|
+
import { writeCookie } from './device-storage.js';
|
|
15
|
+
|
|
16
|
+
export const PREVIEW_PARAM = 'hp_preview';
|
|
17
|
+
|
|
18
|
+
/** Longest token taken from a URL; the console's are far shorter. */
|
|
19
|
+
const MAX_TOKEN_LENGTH = 256;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply the page URL's `hp_preview` to the preview cookie, and return the preview now in force, or
|
|
23
|
+
* null.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} name The cookie name, storageName('preview').
|
|
26
|
+
* @param {string|null} [domain]
|
|
27
|
+
*/
|
|
28
|
+
export function applyPreview(name, domain = null) {
|
|
29
|
+
if (typeof location === 'undefined') return getCookie(name);
|
|
30
|
+
let given = null;
|
|
31
|
+
try {
|
|
32
|
+
given = new URLSearchParams(location.search).get(PREVIEW_PARAM);
|
|
33
|
+
} catch {
|
|
34
|
+
given = null;
|
|
35
|
+
}
|
|
36
|
+
if (given === null) return getCookie(name);
|
|
37
|
+
const token = given.trim();
|
|
38
|
+
if (token === '' || token.length > MAX_TOKEN_LENGTH) {
|
|
39
|
+
writeCookie(name, '', 0, domain);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
writeCookie(name, token, null, domain);
|
|
43
|
+
return token;
|
|
44
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This visit, in the form the recommendations API takes it as query parameters: `viewed`, `basket`
|
|
3
|
+
* and `q` as comma-separated lists, newest first, and `impressed` as `id:count` pairs. The API
|
|
4
|
+
* truncates rather than rejects, so these are its own limits: nothing sent is thrown away there.
|
|
5
|
+
*
|
|
6
|
+
* Every key is optional and present only when it has something in it, so
|
|
7
|
+
* `new URLSearchParams(context)` is the query string to append, and an empty visit appends nothing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { storageName } from './storage-scope.js';
|
|
11
|
+
|
|
12
|
+
/** packages/cdp/src/recommendations/request.ts: MAX_IDS, MAX_ID_LENGTH, five queries, 60 pairs. */
|
|
13
|
+
const MAX_IDS = 40;
|
|
14
|
+
const MAX_ID_LENGTH = 128;
|
|
15
|
+
const MAX_QUERIES = 5;
|
|
16
|
+
const MAX_IMPRESSIONS = 60;
|
|
17
|
+
|
|
18
|
+
/** A comma would split one id into two on the wire. */
|
|
19
|
+
const sendable = (id) =>
|
|
20
|
+
typeof id === 'string' &&
|
|
21
|
+
id !== '' &&
|
|
22
|
+
id.length <= MAX_ID_LENGTH &&
|
|
23
|
+
!id.includes(',');
|
|
24
|
+
|
|
25
|
+
const ids = (list) =>
|
|
26
|
+
Array.isArray(list) ? list.filter(sendable).slice(0, MAX_IDS) : [];
|
|
27
|
+
|
|
28
|
+
const queries = (list) =>
|
|
29
|
+
Array.isArray(list)
|
|
30
|
+
? list
|
|
31
|
+
.filter((q) => typeof q === 'string')
|
|
32
|
+
.map((q) => q.replaceAll(',', ' ').trim())
|
|
33
|
+
.filter((q) => q !== '')
|
|
34
|
+
.slice(0, MAX_QUERIES)
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
/** The products shown most often without being engaged with: those are the ones a rail demotes. */
|
|
38
|
+
const impressions = (counts) =>
|
|
39
|
+
Object.entries(counts && typeof counts === 'object' ? counts : {})
|
|
40
|
+
.filter(([id, n]) => sendable(id) && Number.isFinite(n) && n >= 1)
|
|
41
|
+
.sort(([, a], [, b]) => b - a)
|
|
42
|
+
.slice(0, MAX_IMPRESSIONS)
|
|
43
|
+
.map(([id, n]) => `${id}:${Math.floor(n)}`);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} signals The session signals (`getSessionSignals()` from `./session`).
|
|
47
|
+
* @returns {{ viewed?: string, basket?: string, q?: string, impressed?: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function recommendationContextFrom(signals) {
|
|
50
|
+
const context = {};
|
|
51
|
+
const lists = {
|
|
52
|
+
viewed: ids(signals?.recentProducts),
|
|
53
|
+
basket: ids(signals?.basketProducts),
|
|
54
|
+
q: queries(signals?.recentSearches),
|
|
55
|
+
impressed: impressions(signals?.impressions),
|
|
56
|
+
};
|
|
57
|
+
for (const [name, list] of Object.entries(lists)) {
|
|
58
|
+
if (list.length > 0) context[name] = list.join(',');
|
|
59
|
+
}
|
|
60
|
+
return context;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How long a page's visit stays readable to the next page, from the last event: a web session. */
|
|
64
|
+
const KEPT_FOR_MS = 30 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keep the visit's wire form where the next page can read it synchronously before its SDK has
|
|
68
|
+
* downloaded: a rail requested at page load would otherwise go without it.
|
|
69
|
+
*/
|
|
70
|
+
export function keepVisit(context, now = Date.now()) {
|
|
71
|
+
try {
|
|
72
|
+
localStorage.setItem(
|
|
73
|
+
storageName('recommendationContext'),
|
|
74
|
+
JSON.stringify({ context, until: now + KEPT_FOR_MS }),
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
// Quota or a disabled store: the next page's first rail goes without the visit.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function forgetVisit() {
|
|
82
|
+
try {
|
|
83
|
+
localStorage.removeItem(storageName('recommendationContext'));
|
|
84
|
+
} catch {
|
|
85
|
+
// Nothing was kept.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The visit an earlier page kept, or `{}` once it has lapsed. */
|
|
90
|
+
export function keptVisit(now = Date.now()) {
|
|
91
|
+
try {
|
|
92
|
+
const kept = JSON.parse(
|
|
93
|
+
localStorage.getItem(storageName('recommendationContext')) ?? 'null',
|
|
94
|
+
);
|
|
95
|
+
return kept?.until > now && kept.context && typeof kept.context === 'object'
|
|
96
|
+
? kept.context
|
|
97
|
+
: {};
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
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
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spec as @segment/analytics-next plugins.
|
|
3
|
+
*
|
|
4
|
+
* import { AnalyticsBrowser } from '@segment/analytics-next';
|
|
5
|
+
* import {
|
|
6
|
+
* cdpPlugins, DEFAULT_API_HOST, hpLoadOptions,
|
|
7
|
+
* } from '@thg-agentic/hyper-personalisation-web/segment';
|
|
8
|
+
* import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
|
|
9
|
+
*
|
|
10
|
+
* const hp = { writeKey: 'hp_pk_live_…' };
|
|
11
|
+
* const analytics = AnalyticsBrowser.load(
|
|
12
|
+
* { writeKey: hp.writeKey, cdnURL: DEFAULT_API_HOST, plugins: cdpPlugins({ consent: didomiConsent() }) },
|
|
13
|
+
* hpLoadOptions(hp),
|
|
14
|
+
* );
|
|
15
|
+
*
|
|
16
|
+
* `cdnURL` points analytics-next's settings fetch at `{apiHost}/v1/projects/{writeKey}/settings`,
|
|
17
|
+
* the HP API unless the page proxies it (then both take the proxy's host),
|
|
18
|
+
* the same Segment-compatible path the built-in bootstrap reads. The plugins take the property, the
|
|
19
|
+
* environment, the reported consent purposes, the query-param allowlist and the tracking plan from
|
|
20
|
+
* its `hp` block, so the host passes only what the page knows. hpLoadOptions() does the rest.
|
|
21
|
+
*
|
|
22
|
+
* Two plugins, sharing every rule with the built-in client — the delivery path is the only thing
|
|
23
|
+
* that differs:
|
|
24
|
+
* 'before' strict consent (./consent-gate.js): holds, releases or cancels each event
|
|
25
|
+
* 'enrichment' context shaped to the spec (./context.js), properties pruned (./shape.js)
|
|
26
|
+
*
|
|
27
|
+
* @segment/analytics-consent-tools was considered and not used: it writes
|
|
28
|
+
* `context.consent.categoryPreferences` (not the spec's consent block) and keeps delivering to
|
|
29
|
+
* Segment.io after a withdrawal, which strict mode must not do.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { hostConsent, reportingPurposes } from './consent.js';
|
|
33
|
+
import { createConsentGate } from './consent-gate.js';
|
|
34
|
+
import { buildContext, CONTEXT_KEYS } from './context.js';
|
|
35
|
+
import { deviceIdFrom } from './device-storage.js';
|
|
36
|
+
import { planAllows, readSettings, resolveApiHost } from './settings.js';
|
|
37
|
+
|
|
38
|
+
export { DEFAULT_API_HOST } from './settings.js';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
detectNavigationTrigger,
|
|
42
|
+
PAGE_TYPES,
|
|
43
|
+
pruneProperties,
|
|
44
|
+
} from './shape.js';
|
|
45
|
+
import {
|
|
46
|
+
configureStorageNames,
|
|
47
|
+
migrateLegacyStorage,
|
|
48
|
+
storageKey,
|
|
49
|
+
storageName,
|
|
50
|
+
} from './storage-scope.js';
|
|
51
|
+
import { VERSION } from './version.js';
|
|
52
|
+
|
|
53
|
+
/** Option key that lets the withdrawal record through a gate that is about to close. */
|
|
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';
|
|
60
|
+
|
|
61
|
+
/** Context keys analytics-next fills itself; the spec's versions (scrubbed, site locale) replace them. */
|
|
62
|
+
const LIBRARY_OWNED = new Set([
|
|
63
|
+
'library',
|
|
64
|
+
'page',
|
|
65
|
+
'campaign',
|
|
66
|
+
'locale',
|
|
67
|
+
'device',
|
|
68
|
+
'timezone',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Never taken from a caller: consent comes from the gate, the rest is server-owned. */
|
|
72
|
+
const NOT_OVERRIDABLE = new Set([
|
|
73
|
+
'spec',
|
|
74
|
+
'consent',
|
|
75
|
+
'identity_trust',
|
|
76
|
+
'ip',
|
|
77
|
+
'bot',
|
|
78
|
+
'original_timestamp',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const PAGE_PROPERTY_FIELDS = [
|
|
82
|
+
'url',
|
|
83
|
+
'path',
|
|
84
|
+
'search',
|
|
85
|
+
'referrer',
|
|
86
|
+
'title',
|
|
87
|
+
'type',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const EVENT_TYPES = ['track', 'page', 'identify', 'screen', 'group', 'alias'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A cancellation analytics-next will not retry.
|
|
94
|
+
*
|
|
95
|
+
* Built from the host's own ContextCancelation class, reached through ctx.cancel(), rather than
|
|
96
|
+
* imported: the queue checks `instanceof`, and a second copy of analytics-core in the bundle would
|
|
97
|
+
* make our cancellation look like an ordinary error, which it retries.
|
|
98
|
+
*/
|
|
99
|
+
function cancellation(ctx, reason) {
|
|
100
|
+
try {
|
|
101
|
+
ctx.cancel();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const Cancelation = err?.constructor;
|
|
104
|
+
if (typeof Cancelation === 'function')
|
|
105
|
+
return new Cancelation({ retry: false, type: 'thg_hp', reason });
|
|
106
|
+
}
|
|
107
|
+
return new Error(reason);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `disable` for AnalyticsBrowser.load: true unless the settings response switches the key on. A
|
|
112
|
+
* response with no `hp` block at all (inline `cdnSettings` in development) is not a refusal.
|
|
113
|
+
*/
|
|
114
|
+
export function hpDisabled(cdnSettings) {
|
|
115
|
+
return cdnSettings?.hp !== undefined && !readSettings(cdnSettings).enabled;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The second argument of AnalyticsBrowser.load for a `cdnURL` at the apiHost.
|
|
120
|
+
*
|
|
121
|
+
* analytics-next takes its delivery host and the write key it puts in every body from the settings'
|
|
122
|
+
* `integrations['Segment.io']` block, and without one sends to api.segment.io — its own telemetry
|
|
123
|
+
* too. The gateway's settings carry that block, pointing at the collection host, so a page that
|
|
124
|
+
* sets only `cdnURL` already delivers to us. This keeps the settings' block when there is one and
|
|
125
|
+
* writes the same block from `apiHost` when there is not (inline `cdnSettings`, a key the settings
|
|
126
|
+
* do not enable), turns telemetry off, makes a key the settings do not enable a no-op, and keeps
|
|
127
|
+
* analytics-next from writing its own id cookies: it would write them before the consent plugin runs.
|
|
128
|
+
*
|
|
129
|
+
* @param {{ writeKey: string, apiHost?: string }} config The same config bootstrap() takes.
|
|
130
|
+
*/
|
|
131
|
+
export function hpLoadOptions({ writeKey, apiHost }) {
|
|
132
|
+
return {
|
|
133
|
+
disable: hpDisabled,
|
|
134
|
+
disableClientPersistence: true,
|
|
135
|
+
updateCDNSettings(cdnSettings) {
|
|
136
|
+
const integrations = { ...cdnSettings.integrations };
|
|
137
|
+
if (!integrations['Segment.io']?.apiHost) {
|
|
138
|
+
const origin =
|
|
139
|
+
readSettings(cdnSettings).apiHost ?? resolveApiHost(apiHost);
|
|
140
|
+
if (!origin) return cdnSettings;
|
|
141
|
+
const { protocol, host } = new URL(origin);
|
|
142
|
+
integrations['Segment.io'] = {
|
|
143
|
+
apiKey: writeKey,
|
|
144
|
+
apiHost: `${host}/v1`,
|
|
145
|
+
protocol: protocol.replace(':', ''),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...cdnSettings,
|
|
150
|
+
integrations,
|
|
151
|
+
metrics: { ...cdnSettings.metrics, sampleRate: 0 },
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {object} options Same names and meanings as createAnalytics().
|
|
159
|
+
* @param {string} [options.propertyId] For a key scoped to several properties, or without settings:
|
|
160
|
+
* the `hp` block's propertyId wins when it has one.
|
|
161
|
+
* @param {string} [options.channel]
|
|
162
|
+
* @param {string} [options.market]
|
|
163
|
+
* @param {string} [options.environment]
|
|
164
|
+
* @param {string} [options.currency]
|
|
165
|
+
* @param {string} [options.locale]
|
|
166
|
+
* @param {string} [options.country]
|
|
167
|
+
* @param {string} [options.shipToCountry]
|
|
168
|
+
* @param {() => string|null} [options.getCurrency] Read per event; wins over `currency`.
|
|
169
|
+
* @param {() => string|null} [options.getShipToCountry] Read per event; wins over `shipToCountry`.
|
|
170
|
+
* @param {() => string|null} [options.getLocale] Read per event; wins over `locale`.
|
|
171
|
+
* @param {import('./consent.js').ConsentAdapter} [options.consent]
|
|
172
|
+
* @param {number} [options.consentTimeoutMs]
|
|
173
|
+
* @param {string} [options.consentCookieDomain]
|
|
174
|
+
* @param {object} [options.storageNames] Renames what is stored; see storage-scope.js.
|
|
175
|
+
* @param {string[]} [options.queryParamAllowlist] Only without settings: the `hp` block's wins.
|
|
176
|
+
* @param {string[]} [options.queryParamBlocklist]
|
|
177
|
+
* @param {string[]} [options.sensitiveParamAllowlist]
|
|
178
|
+
* @param {() => string|null} [options.getAnonymousId] Pin analytics-next to the site's device id.
|
|
179
|
+
* The settings' `hp.anonymousId` decides where device ids come from (3.6): a site cookie they
|
|
180
|
+
* name, or Hyper-Personalisation's cookie, replaces it.
|
|
181
|
+
* @param {object} [options.anonymousIdSource] Only without settings: the `hp` block's wins.
|
|
182
|
+
* @param {() => string|null} [options.getPageType]
|
|
183
|
+
* @param {() => string|null} [options.getSessionId]
|
|
184
|
+
* @param {() => string|null} [options.getRenderId]
|
|
185
|
+
* @param {() => object|null} [options.getExtensions]
|
|
186
|
+
* @param {() => object[]|null} [options.getExperiments]
|
|
187
|
+
* @param {() => object[]|null} [options.getAudiences]
|
|
188
|
+
* @param {() => object[]|null} [options.getDecisions]
|
|
189
|
+
* @param {() => object[]|null} [options.getRecommendations]
|
|
190
|
+
* @returns {object[]} analytics-next plugins
|
|
191
|
+
*/
|
|
192
|
+
export function cdpPlugins(options = {}) {
|
|
193
|
+
const cfg = { queryParamBlocklist: [], consentTimeoutMs: 5000, ...options };
|
|
194
|
+
let gate = null;
|
|
195
|
+
let pageType = null;
|
|
196
|
+
let lastPageMessageId = null;
|
|
197
|
+
let prevPageKey = null;
|
|
198
|
+
let off = null;
|
|
199
|
+
let device = null;
|
|
200
|
+
|
|
201
|
+
cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Once, at the first plugin load: the tenant's settings from the response analytics-next
|
|
205
|
+
* fetched, over the local options. Everything tenant-level comes from there; without an `hp`
|
|
206
|
+
* block (inline cdnSettings in development) the local options stand.
|
|
207
|
+
*/
|
|
208
|
+
function configure(analytics) {
|
|
209
|
+
if (off !== null) return;
|
|
210
|
+
const cdnSettings = analytics?.settings?.cdnSettings;
|
|
211
|
+
if (cdnSettings?.hp !== undefined) {
|
|
212
|
+
const settings = readSettings(cdnSettings);
|
|
213
|
+
if (settings.enabled) {
|
|
214
|
+
cfg.propertyId = settings.propertyId ?? cfg.propertyId;
|
|
215
|
+
cfg.environment = settings.environment;
|
|
216
|
+
if (settings.queryParamAllowlist)
|
|
217
|
+
cfg.queryParamAllowlist = settings.queryParamAllowlist;
|
|
218
|
+
if (settings.anonymousId) cfg.anonymousIdSource = settings.anonymousId;
|
|
219
|
+
cfg.plan = settings.plan;
|
|
220
|
+
if (cfg.consent)
|
|
221
|
+
cfg.consent = reportingPurposes(
|
|
222
|
+
cfg.consent,
|
|
223
|
+
settings.consentPurposes,
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
cfg.propertyId = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
off = !cfg.propertyId;
|
|
230
|
+
if (off) return;
|
|
231
|
+
configureStorageNames(cfg.storageNames);
|
|
232
|
+
migrateLegacyStorage();
|
|
233
|
+
prevPageKey = storageKey(storageName('previousPage'), cfg.propertyId);
|
|
234
|
+
device = deviceIdFrom(cfg, {
|
|
235
|
+
mayStore: () => !cfg.consent || gate?.status === 'granted',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function admit(ctx) {
|
|
240
|
+
const context = ctx.event.context ?? {};
|
|
241
|
+
const forced = context[WITHDRAWAL_FLAG] === true;
|
|
242
|
+
const withdrawnAs = context[WITHDRAWN_AS];
|
|
243
|
+
delete context[WITHDRAWAL_FLAG];
|
|
244
|
+
delete context[WITHDRAWN_AS];
|
|
245
|
+
|
|
246
|
+
if (off) throw cancellation(ctx, 'not enabled for this write key');
|
|
247
|
+
if (ctx.event.type === 'track' && !planAllows(cfg.plan, ctx.event.event)) {
|
|
248
|
+
throw cancellation(ctx, 'disabled by the tracking plan');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const anonymousId =
|
|
252
|
+
forced && withdrawnAs ? withdrawnAs : cfg.getAnonymousId?.();
|
|
253
|
+
if (anonymousId) ctx.updateEvent('anonymousId', String(anonymousId));
|
|
254
|
+
|
|
255
|
+
if (!gate || forced || gate.admits()) return ctx;
|
|
256
|
+
// Still unanswered, before or after the timeout: held until the CMP answers.
|
|
257
|
+
if (gate.status !== 'pending' && gate.status !== 'unknown')
|
|
258
|
+
throw cancellation(ctx, `consent ${gate.status}`);
|
|
259
|
+
// Holding one event pauses analytics-next's queue behind it, which keeps order and means
|
|
260
|
+
// nothing else is sent before the answer either.
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
gate.hold({
|
|
263
|
+
resolve: () => resolve(ctx),
|
|
264
|
+
reject: () => reject(cancellation(ctx, 'consent not granted')),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const readPreviousPage = () => {
|
|
270
|
+
if (lastPageMessageId) return lastPageMessageId;
|
|
271
|
+
try {
|
|
272
|
+
return sessionStorage.getItem(prevPageKey) || null;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const writePreviousPage = (id) => {
|
|
279
|
+
lastPageMessageId = id;
|
|
280
|
+
if (gate && gate.status !== 'granted') return;
|
|
281
|
+
try {
|
|
282
|
+
sessionStorage.setItem(prevPageKey, id);
|
|
283
|
+
} catch {
|
|
284
|
+
/* the chain still holds within this document */
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function shapePage(event) {
|
|
289
|
+
const props = event.properties ?? {};
|
|
290
|
+
if (PAGE_TYPES.has(props.type)) pageType = props.type;
|
|
291
|
+
for (const field of PAGE_PROPERTY_FIELDS) delete props[field];
|
|
292
|
+
if (event.category != null) props.category ??= event.category;
|
|
293
|
+
delete event.category;
|
|
294
|
+
props.navigation_trigger ??= detectNavigationTrigger();
|
|
295
|
+
props.previous_message_id ??= readPreviousPage();
|
|
296
|
+
writePreviousPage(event.messageId);
|
|
297
|
+
event.properties = props;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function enrich(ctx) {
|
|
301
|
+
const event = ctx.event;
|
|
302
|
+
if (event.type === 'page') shapePage(event);
|
|
303
|
+
|
|
304
|
+
const theirs = event.context ?? {};
|
|
305
|
+
const ours = buildContext({
|
|
306
|
+
...cfg,
|
|
307
|
+
pageType: cfg.getPageType?.() ?? pageType,
|
|
308
|
+
});
|
|
309
|
+
const context = {};
|
|
310
|
+
for (const key of CONTEXT_KEYS) {
|
|
311
|
+
const override =
|
|
312
|
+
!LIBRARY_OWNED.has(key) &&
|
|
313
|
+
!NOT_OVERRIDABLE.has(key) &&
|
|
314
|
+
theirs[key] !== undefined;
|
|
315
|
+
context[key] = override ? theirs[key] : ours[key];
|
|
316
|
+
}
|
|
317
|
+
context.library = theirs.library ?? {
|
|
318
|
+
name: '@thg-agentic/hyper-personalisation-web/segment',
|
|
319
|
+
version: VERSION,
|
|
320
|
+
};
|
|
321
|
+
context.identity_trust = event.anonymousId ? 'device' : 'none';
|
|
322
|
+
event.context = context;
|
|
323
|
+
|
|
324
|
+
if (event.properties) event.properties = pruneProperties(event.properties);
|
|
325
|
+
return ctx;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const hooks = (fn) =>
|
|
329
|
+
Object.fromEntries(EVENT_TYPES.map((type) => [type, fn]));
|
|
330
|
+
|
|
331
|
+
const consentPlugin = {
|
|
332
|
+
name: 'THG Hyper-Personalisation Consent',
|
|
333
|
+
type: 'before',
|
|
334
|
+
version: VERSION,
|
|
335
|
+
isLoaded: () => true,
|
|
336
|
+
load(_ctx, analytics) {
|
|
337
|
+
configure(analytics);
|
|
338
|
+
if (off || !cfg.consent) return Promise.resolve();
|
|
339
|
+
gate = createConsentGate({
|
|
340
|
+
adapter: cfg.consent,
|
|
341
|
+
timeoutMs: cfg.consentTimeoutMs,
|
|
342
|
+
cookieDomain: cfg.consentCookieDomain,
|
|
343
|
+
debug: cfg.debug,
|
|
344
|
+
release: (item) => item.resolve(),
|
|
345
|
+
discard: (item) => item.reject(),
|
|
346
|
+
onStatus(status) {
|
|
347
|
+
if (status === 'granted') {
|
|
348
|
+
device?.persist();
|
|
349
|
+
if (lastPageMessageId) writePreviousPage(lastPageMessageId);
|
|
350
|
+
}
|
|
351
|
+
if (status === 'denied') device?.forget();
|
|
352
|
+
},
|
|
353
|
+
announce({ action, state, withdrawal }) {
|
|
354
|
+
analytics.track(
|
|
355
|
+
'Consent Updated',
|
|
356
|
+
{ action, state },
|
|
357
|
+
withdrawal
|
|
358
|
+
? {
|
|
359
|
+
[WITHDRAWAL_FLAG]: true,
|
|
360
|
+
[WITHDRAWN_AS]: cfg.getAnonymousId?.() ?? null,
|
|
361
|
+
}
|
|
362
|
+
: undefined,
|
|
363
|
+
);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
gate.start();
|
|
367
|
+
return Promise.resolve();
|
|
368
|
+
},
|
|
369
|
+
unload() {
|
|
370
|
+
gate?.stop();
|
|
371
|
+
},
|
|
372
|
+
...hooks(admit),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const contextPlugin = {
|
|
376
|
+
name: 'THG Hyper-Personalisation Context',
|
|
377
|
+
type: 'enrichment',
|
|
378
|
+
version: VERSION,
|
|
379
|
+
isLoaded: () => true,
|
|
380
|
+
load(_ctx, analytics) {
|
|
381
|
+
configure(analytics);
|
|
382
|
+
return Promise.resolve();
|
|
383
|
+
},
|
|
384
|
+
...hooks(enrich),
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return [consentPlugin, contextPlugin];
|
|
388
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** One spelling for a category: `Apparel / Clothing`, `/apparel/clothing/` → `apparel/clothing`. */
|
|
2
|
+
export function categoryKey(value) {
|
|
3
|
+
return value
|
|
4
|
+
.trim()
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/\s*\/\s*/g, '/')
|
|
7
|
+
.replace(/^\/+|\/+$/g, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Matches the warehouse's `cdp_path_prefix(..., 2)`: a dimension must be mutually exclusive to be
|
|
12
|
+
* worth a share, and uncut sub-paths would split one interest across several.
|
|
13
|
+
*/
|
|
14
|
+
const CATEGORY_DEPTH = 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How many category interactions an affinity is measured over: the session-scale counterpart of
|
|
18
|
+
* the warehouse's affinity half-life.
|
|
19
|
+
*/
|
|
20
|
+
export const AFFINITY_WINDOW = 12;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A category path cut to its first `depth` segments: the browser's `cdp_path_prefix`.
|
|
24
|
+
*
|
|
25
|
+
* Kept apart from `categoryKey` so both sides compose the same two primitives, which
|
|
26
|
+
* `assert_category_key_matches_sdk` checks.
|
|
27
|
+
*/
|
|
28
|
+
export function pathPrefix(value, depth = CATEGORY_DEPTH) {
|
|
29
|
+
return value.split('/').slice(0, depth).join('/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Record one interaction with a category, oldest falling off the end. Returns the key used. */
|
|
33
|
+
export function bump(trail, value) {
|
|
34
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
35
|
+
const key = pathPrefix(categoryKey(value));
|
|
36
|
+
if (!key) return null;
|
|
37
|
+
trail.unshift(key);
|
|
38
|
+
trail.length = Math.min(trail.length, AFFINITY_WINDOW);
|
|
39
|
+
return key;
|
|
40
|
+
}
|