@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1
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 +825 -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 +476 -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/decisions.js +90 -0
- package/src/device-storage.js +201 -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 +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery for the CDP browser SDK.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the Segment wire protocol (`POST <apiHost>/v1/batch`).
|
|
5
|
+
*
|
|
6
|
+
* 1. Persisted retry: every batch is written to localStorage before its first attempt and
|
|
7
|
+
* removed only once the ingest has answered for it.
|
|
8
|
+
* 2. Unload delivery: on pagehide the queue goes by sendBeacon, which outlives the document.
|
|
9
|
+
* 3. Bounded: queue size, retry age and payload size are capped, so a broken ingest degrades
|
|
10
|
+
* instead of filling the user's disk.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_STORAGE_NAMES } from './storage-scope.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
flushAt: 20,
|
|
17
|
+
flushIntervalMs: 10_000,
|
|
18
|
+
maxQueue: 500,
|
|
19
|
+
maxRetryAgeMs: 24 * 60 * 60 * 1000,
|
|
20
|
+
maxRetries: 5,
|
|
21
|
+
// What the retry buffer may occupy. localStorage is ~5MB per ORIGIN and shared with the host
|
|
22
|
+
// page, so an ingest outage must not be able to take all of it; the oldest batch goes first.
|
|
23
|
+
maxPersistedBatches: 20,
|
|
24
|
+
maxPersistedBytes: 1024 * 1024,
|
|
25
|
+
// createAnalytics passes a per-property key built from `storageNames.retry`.
|
|
26
|
+
storageKey: DEFAULT_STORAGE_NAMES.retry,
|
|
27
|
+
// sendBeacon hard-fails above ~64KB. Stay clear of the cliff.
|
|
28
|
+
beaconSafeBytes: 56 * 1024,
|
|
29
|
+
// The ingest accepts 1MB per request; half that leaves room for a proxy's own limits.
|
|
30
|
+
maxBatchEvents: 100,
|
|
31
|
+
maxBatchBytes: 500 * 1024,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const now = () => Date.now();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* UTF-8 bytes, the unit of sendBeacon's ceiling and the keepalive budget. String length counts
|
|
38
|
+
* UTF-16 code units and undercounts any non-ASCII catalogue.
|
|
39
|
+
*/
|
|
40
|
+
const byteLength = (s) =>
|
|
41
|
+
typeof TextEncoder !== 'undefined'
|
|
42
|
+
? new TextEncoder().encode(s).length
|
|
43
|
+
: Buffer.byteLength(s);
|
|
44
|
+
const isBrowser = () =>
|
|
45
|
+
typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* localStorage if it can be reached, probed by a read: under strict consent nothing may be written
|
|
49
|
+
* before the visitor answers. A write that fails later is caught where it happens.
|
|
50
|
+
*/
|
|
51
|
+
function safeStorage() {
|
|
52
|
+
try {
|
|
53
|
+
if (!isBrowser() || !window.localStorage) return null;
|
|
54
|
+
window.localStorage.getItem('thg_hp_probe');
|
|
55
|
+
return window.localStorage;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class Transport {
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} opts.apiHost e.g. 'http://localhost:8080' — the /v1/batch base
|
|
65
|
+
* @param {string} opts.writeKey
|
|
66
|
+
* @param {(msg:string, ...a:any[])=>void} [opts.onError]
|
|
67
|
+
* @param {() => boolean} [opts.mayPersist] Whether a batch may be written to storage now. Strict
|
|
68
|
+
* consent passes "analytics is granted"; without it, persistence is always allowed.
|
|
69
|
+
* @param {boolean} [opts.deferReplay] Do not replay persisted batches until replayPersisted() is
|
|
70
|
+
* called. Strict consent sets it: a replay is a send, and it must wait for the gate like any other.
|
|
71
|
+
*/
|
|
72
|
+
constructor(opts) {
|
|
73
|
+
this.o = { ...DEFAULTS, ...opts };
|
|
74
|
+
this.queue = [];
|
|
75
|
+
this.timer = null;
|
|
76
|
+
this.storage = safeStorage();
|
|
77
|
+
this.inflight = null;
|
|
78
|
+
// Ids of persisted entries this instance is already sending, so a replay never races them.
|
|
79
|
+
this.sending = new Set();
|
|
80
|
+
this.replayAllowed = !this.o.deferReplay;
|
|
81
|
+
this.draining = null;
|
|
82
|
+
|
|
83
|
+
if (isBrowser()) {
|
|
84
|
+
// pagehide is the unload, and the beacon the only send sure to outlive it. A hidden tab may
|
|
85
|
+
// come back, so it takes the normal path: persisted first, retried, and sent with keepalive.
|
|
86
|
+
window.addEventListener('pagehide', () => this.flush({ beacon: true }));
|
|
87
|
+
document.addEventListener('visibilitychange', () => {
|
|
88
|
+
if (document.visibilityState === 'hidden') this.flush();
|
|
89
|
+
});
|
|
90
|
+
this.#drainPersisted();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Allow, and start, the replay of persisted batches. Idempotent. */
|
|
95
|
+
replayPersisted() {
|
|
96
|
+
this.replayAllowed = true;
|
|
97
|
+
return this.#drainPersisted();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Forget every persisted batch and stop replaying. For a refusal or a withdrawal: what was kept
|
|
102
|
+
* for a later page must not outlive the permission it was kept under.
|
|
103
|
+
*/
|
|
104
|
+
clearPersisted() {
|
|
105
|
+
this.replayAllowed = false;
|
|
106
|
+
if (!this.storage) return;
|
|
107
|
+
try {
|
|
108
|
+
this.storage.removeItem(this.o.storageKey);
|
|
109
|
+
} catch {
|
|
110
|
+
/* nothing was readable, so nothing will be replayed either */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
enqueue(event) {
|
|
115
|
+
this.queue.push(event);
|
|
116
|
+
if (this.queue.length > this.o.maxQueue) {
|
|
117
|
+
// Drop oldest: recent behaviour is more useful than a complete-but-stale tail.
|
|
118
|
+
this.queue.splice(0, this.queue.length - this.o.maxQueue);
|
|
119
|
+
}
|
|
120
|
+
if (this.queue.length >= this.o.flushAt) {
|
|
121
|
+
this.flush();
|
|
122
|
+
} else {
|
|
123
|
+
this.#schedule();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#schedule() {
|
|
128
|
+
if (this.timer) return;
|
|
129
|
+
this.timer = setTimeout(() => this.flush(), this.o.flushIntervalMs);
|
|
130
|
+
// A pending batch must not keep a server-rendering process alive; a no-op in browsers.
|
|
131
|
+
this.timer?.unref?.();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#clearTimer() {
|
|
135
|
+
if (this.timer) {
|
|
136
|
+
clearTimeout(this.timer);
|
|
137
|
+
this.timer = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#body(batch) {
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
batch,
|
|
144
|
+
writeKey: this.o.writeKey,
|
|
145
|
+
sentAt: new Date().toISOString(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#url() {
|
|
150
|
+
return `${this.o.apiHost.replace(/\/$/, '')}/v1/batch`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Split events into batches of at most `maxEvents` whose request body stays within `maxBytes`.
|
|
155
|
+
* An event that is too big on its own still gets a batch, so it is attempted rather than lost.
|
|
156
|
+
*/
|
|
157
|
+
#chunk(events, maxEvents, maxBytes) {
|
|
158
|
+
const envelope = byteLength(this.#body([]));
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let current = [];
|
|
161
|
+
let bytes = envelope;
|
|
162
|
+
for (const event of events) {
|
|
163
|
+
const size = byteLength(JSON.stringify(event)) + 1;
|
|
164
|
+
if (
|
|
165
|
+
current.length &&
|
|
166
|
+
(current.length >= maxEvents || bytes + size > maxBytes)
|
|
167
|
+
) {
|
|
168
|
+
chunks.push(current);
|
|
169
|
+
current = [];
|
|
170
|
+
bytes = envelope;
|
|
171
|
+
}
|
|
172
|
+
current.push(event);
|
|
173
|
+
bytes += size;
|
|
174
|
+
}
|
|
175
|
+
if (current.length) chunks.push(current);
|
|
176
|
+
return chunks;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {{beacon?: boolean}} [opts] beacon=true is the unload path: synchronous
|
|
181
|
+
* handoff to the browser, no retry possible, so it must not be the normal path.
|
|
182
|
+
*/
|
|
183
|
+
flush(opts = {}) {
|
|
184
|
+
this.#clearTimer();
|
|
185
|
+
if (this.queue.length === 0) return Promise.resolve();
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
opts.beacon &&
|
|
189
|
+
isBrowser() &&
|
|
190
|
+
typeof navigator !== 'undefined' &&
|
|
191
|
+
navigator.sendBeacon
|
|
192
|
+
) {
|
|
193
|
+
this.#flushBeacon();
|
|
194
|
+
return Promise.resolve();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One request in flight at a time; what accumulates meanwhile stays queued and goes next.
|
|
198
|
+
if (this.inflight) return this.inflight;
|
|
199
|
+
|
|
200
|
+
// Every batch is persisted before the first request leaves, so a page closed or crashed mid-retry
|
|
201
|
+
// loses nothing.
|
|
202
|
+
const batches = this.#chunk(
|
|
203
|
+
this.queue,
|
|
204
|
+
this.o.maxBatchEvents,
|
|
205
|
+
this.o.maxBatchBytes,
|
|
206
|
+
).map((batch) => {
|
|
207
|
+
const id = this.#persist(batch);
|
|
208
|
+
// Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
|
|
209
|
+
if (id) this.sending.add(id);
|
|
210
|
+
return { batch, id };
|
|
211
|
+
});
|
|
212
|
+
this.queue = [];
|
|
213
|
+
this.inflight = (async () => {
|
|
214
|
+
for (const { batch, id } of batches) await this.#send(batch, 0, id);
|
|
215
|
+
})().finally(() => {
|
|
216
|
+
this.inflight = null;
|
|
217
|
+
if (this.queue.length >= this.o.flushAt) this.flush();
|
|
218
|
+
else if (this.queue.length) this.#schedule();
|
|
219
|
+
});
|
|
220
|
+
return this.inflight;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Unload: every chunk that fits goes by beacon. A chunk the browser refuses (its keepalive
|
|
225
|
+
* quota is shared across the page) is persisted for the next page load AND tried by fetch;
|
|
226
|
+
* a fetch that lands removes it again, and a duplicate is deduplicated on messageId downstream.
|
|
227
|
+
*
|
|
228
|
+
* The fetch builds its own body rather than reusing the beacon's, for the same reason every retry
|
|
229
|
+
* does: see #send.
|
|
230
|
+
*/
|
|
231
|
+
#flushBeacon() {
|
|
232
|
+
const events = this.queue;
|
|
233
|
+
this.queue = [];
|
|
234
|
+
const url = this.#url();
|
|
235
|
+
for (const batch of this.#chunk(
|
|
236
|
+
events,
|
|
237
|
+
this.o.maxBatchEvents,
|
|
238
|
+
this.o.beaconSafeBytes,
|
|
239
|
+
)) {
|
|
240
|
+
const body = this.#body(batch);
|
|
241
|
+
if (byteLength(body) <= this.o.beaconSafeBytes) {
|
|
242
|
+
let ok = false;
|
|
243
|
+
try {
|
|
244
|
+
// text/plain keeps this a CORS-safelisted request, avoiding a preflight
|
|
245
|
+
// that would never complete during unload.
|
|
246
|
+
ok = navigator.sendBeacon(
|
|
247
|
+
url,
|
|
248
|
+
new Blob([body], { type: 'text/plain' }),
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
ok = false;
|
|
252
|
+
}
|
|
253
|
+
if (ok) continue;
|
|
254
|
+
}
|
|
255
|
+
this.#send(batch, 0, this.#persist(batch));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Deliver one batch, retrying in memory with backoff. `persisted` is its storage id, removed only
|
|
261
|
+
* when the ingest has answered for it — 2xx, or a 4xx no retry can fix. Anything else leaves it
|
|
262
|
+
* stored for the next page load.
|
|
263
|
+
*
|
|
264
|
+
* The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
|
|
265
|
+
* stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
|
|
266
|
+
*/
|
|
267
|
+
async #send(batch, attempt, persisted = null) {
|
|
268
|
+
if (persisted) this.sending.add(persisted);
|
|
269
|
+
const body = this.#body(batch);
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetch(this.#url(), {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
274
|
+
body,
|
|
275
|
+
// Over the keepalive byte budget the request is rejected, not downgraded.
|
|
276
|
+
keepalive: byteLength(body) <= this.o.beaconSafeBytes,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (res.ok) {
|
|
280
|
+
this.#settle(persisted);
|
|
281
|
+
// The ingest is answering again: whatever an earlier outage left behind can go now.
|
|
282
|
+
this.#drainPersisted();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
// 4xx (except 429) means the payload is wrong; retrying cannot fix it and
|
|
286
|
+
// would loop forever. Drop and surface.
|
|
287
|
+
const retryable = res.status === 429 || res.status >= 500;
|
|
288
|
+
if (!retryable) {
|
|
289
|
+
this.#settle(persisted);
|
|
290
|
+
this.o.onError?.(
|
|
291
|
+
`CDP ingest rejected batch (${res.status}); dropping ${batch.length} events`,
|
|
292
|
+
);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`retryable ${res.status}`);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (attempt >= this.o.maxRetries) {
|
|
298
|
+
if (persisted) this.sending.delete(persisted);
|
|
299
|
+
const fate = persisted ? 'kept for the next page' : 'dropped';
|
|
300
|
+
this.o.onError?.(
|
|
301
|
+
`CDP ingest unreachable after ${attempt} attempts; ${fate}: ${batch.length} events`,
|
|
302
|
+
err,
|
|
303
|
+
);
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const delay =
|
|
307
|
+
Math.min(1000 * 2 ** attempt, 30_000) * (0.5 + Math.random());
|
|
308
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
309
|
+
return this.#send(batch, attempt + 1, persisted);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#settle(id) {
|
|
314
|
+
if (!id) return;
|
|
315
|
+
this.sending.delete(id);
|
|
316
|
+
this.#unpersist(id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#readPersisted() {
|
|
320
|
+
const entries = JSON.parse(this.storage.getItem(this.o.storageKey) || '[]');
|
|
321
|
+
return Array.isArray(entries) ? entries : [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Store `entries` plus `entry`, newest last, within the count and byte caps: the oldest go first,
|
|
326
|
+
* and a write the browser refuses (quota) is retried with fewer. The new entry is always kept if
|
|
327
|
+
* anything is — callers check it fits the byte cap on its own.
|
|
328
|
+
* @returns {boolean} whether it was written.
|
|
329
|
+
*/
|
|
330
|
+
#writePersisted(entries, entry) {
|
|
331
|
+
const size = (e) => byteLength(JSON.stringify(e)) + 1;
|
|
332
|
+
let kept = [...entries, entry].slice(-this.o.maxPersistedBatches);
|
|
333
|
+
let bytes = kept.reduce((sum, e) => sum + size(e), 2);
|
|
334
|
+
while (kept.length > 1 && bytes > this.o.maxPersistedBytes) {
|
|
335
|
+
bytes -= size(kept[0]);
|
|
336
|
+
kept = kept.slice(1);
|
|
337
|
+
}
|
|
338
|
+
while (kept.length) {
|
|
339
|
+
try {
|
|
340
|
+
this.storage.setItem(this.o.storageKey, JSON.stringify(kept));
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
kept = kept.slice(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @returns {string|null} an id for #unpersist, or null when nothing was stored. */
|
|
350
|
+
#persist(batch) {
|
|
351
|
+
if (!this.storage) return null;
|
|
352
|
+
if (this.o.mayPersist && !this.o.mayPersist()) return null;
|
|
353
|
+
try {
|
|
354
|
+
const entry = {
|
|
355
|
+
at: now(),
|
|
356
|
+
id: `${now()}-${Math.random().toString(36).slice(2)}`,
|
|
357
|
+
batch,
|
|
358
|
+
};
|
|
359
|
+
// A batch bigger than the whole budget is sent without a second chance rather than being
|
|
360
|
+
// allowed to evict every other batch on its way to not fitting either.
|
|
361
|
+
if (byteLength(JSON.stringify(entry)) + 3 > this.o.maxPersistedBytes)
|
|
362
|
+
return null;
|
|
363
|
+
return this.#writePersisted(this.#readPersisted(), entry)
|
|
364
|
+
? entry.id
|
|
365
|
+
: null;
|
|
366
|
+
} catch {
|
|
367
|
+
// Storage unreadable or unwritable: the batch is still sent, it just has no second chance.
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#unpersist(id) {
|
|
373
|
+
if (!id || !this.storage) return;
|
|
374
|
+
try {
|
|
375
|
+
const kept = this.#readPersisted().filter((entry) => entry.id !== id);
|
|
376
|
+
if (kept.length)
|
|
377
|
+
this.storage.setItem(this.o.storageKey, JSON.stringify(kept));
|
|
378
|
+
else this.storage.removeItem(this.o.storageKey);
|
|
379
|
+
} catch {
|
|
380
|
+
/* a replay of already-delivered events is deduplicated on messageId */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Re-send what an earlier page (or an earlier outage on this one) left behind, one batch at a
|
|
386
|
+
* time. Nothing but stale entries is removed until its send succeeds. Another tab replaying the
|
|
387
|
+
* same batch is a duplicate, which stg_events removes on messageId.
|
|
388
|
+
*/
|
|
389
|
+
#drainPersisted() {
|
|
390
|
+
if (!this.storage || !this.replayAllowed)
|
|
391
|
+
return this.draining ?? Promise.resolve();
|
|
392
|
+
if (this.draining) return this.draining;
|
|
393
|
+
let entries;
|
|
394
|
+
try {
|
|
395
|
+
const all = this.#readPersisted();
|
|
396
|
+
entries = all
|
|
397
|
+
.filter(
|
|
398
|
+
(entry) =>
|
|
399
|
+
Array.isArray(entry?.batch) &&
|
|
400
|
+
now() - entry.at < this.o.maxRetryAgeMs,
|
|
401
|
+
)
|
|
402
|
+
// An entry with no id cannot be removed on its own once delivered, so it is given one.
|
|
403
|
+
.map((entry) =>
|
|
404
|
+
entry.id
|
|
405
|
+
? entry
|
|
406
|
+
: {
|
|
407
|
+
...entry,
|
|
408
|
+
id: `${entry.at}-${Math.random().toString(36).slice(2)}`,
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
if (
|
|
412
|
+
entries.length !== all.length ||
|
|
413
|
+
entries.some((entry, i) => entry !== all[i])
|
|
414
|
+
) {
|
|
415
|
+
if (entries.length)
|
|
416
|
+
this.storage.setItem(this.o.storageKey, JSON.stringify(entries));
|
|
417
|
+
else this.storage.removeItem(this.o.storageKey);
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
return Promise.resolve();
|
|
421
|
+
}
|
|
422
|
+
const pending = entries.filter((entry) => !this.sending.has(entry.id));
|
|
423
|
+
if (!pending.length) return Promise.resolve();
|
|
424
|
+
this.draining = (async () => {
|
|
425
|
+
for (const entry of pending) {
|
|
426
|
+
if (!this.replayAllowed) return;
|
|
427
|
+
// Re-sent, not re-enqueued, so messageIds are preserved. A batch that exhausts its retries
|
|
428
|
+
// stops the replay: the ingest is down.
|
|
429
|
+
if (!(await this.#send(entry.batch, 0, entry.id))) return;
|
|
430
|
+
}
|
|
431
|
+
})().finally(() => {
|
|
432
|
+
this.draining = null;
|
|
433
|
+
});
|
|
434
|
+
return this.draining;
|
|
435
|
+
}
|
|
436
|
+
}
|
package/src/version.js
ADDED
package/types/auto.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AnalyticsClient } from './index';
|
|
2
|
+
|
|
3
|
+
export interface ViewabilityOptions {
|
|
4
|
+
/** Fraction of the element on screen. Default 0.5 (MRC). */
|
|
5
|
+
visibleRatio?: number;
|
|
6
|
+
/** Continuous time on screen. Default 1000ms (MRC). */
|
|
7
|
+
dwellMs?: number;
|
|
8
|
+
root?: Document | Element;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TrackPromotionsOptions extends ViewabilityOptions {
|
|
12
|
+
selector?: string;
|
|
13
|
+
resolve?(el: Element, index: number): Record<string, unknown> | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A list is `data-hp-list="<id>"`; its tiles carry `data-hp-product`, the spec product as JSON. */
|
|
17
|
+
export interface TrackProductListsOptions extends ViewabilityOptions {
|
|
18
|
+
selector?: string;
|
|
19
|
+
tileSelector?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TrackRecommendationRailsOptions extends ViewabilityOptions {
|
|
23
|
+
selector?: string;
|
|
24
|
+
tileSelector?: string;
|
|
25
|
+
resolveRail?(el: Element): Record<string, unknown> | null;
|
|
26
|
+
resolveTile?(el: Element, index: number): Record<string, unknown> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An element is `data-hp-exposure="<surface>"`: recommendations, decisions or audiences (3.15). */
|
|
30
|
+
export interface TrackExposuresOptions extends ViewabilityOptions {
|
|
31
|
+
selector?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns a teardown. */
|
|
35
|
+
export declare function trackPromotions(
|
|
36
|
+
analytics: AnalyticsClient,
|
|
37
|
+
options?: TrackPromotionsOptions,
|
|
38
|
+
): () => void;
|
|
39
|
+
export declare function trackProductLists(
|
|
40
|
+
analytics: AnalyticsClient,
|
|
41
|
+
options?: TrackProductListsOptions,
|
|
42
|
+
): () => void;
|
|
43
|
+
export declare function trackRecommendationRails(
|
|
44
|
+
analytics: AnalyticsClient,
|
|
45
|
+
options?: TrackRecommendationRailsOptions,
|
|
46
|
+
): () => void;
|
|
47
|
+
/** `surfaceViewed` for each `data-hp-exposure` element once it is viewable (3.15). */
|
|
48
|
+
export declare function trackExposures(
|
|
49
|
+
analytics: Pick<AnalyticsClient, 'surfaceViewed'>,
|
|
50
|
+
options?: TrackExposuresOptions,
|
|
51
|
+
): () => void;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TrackExposuresOptions,
|
|
3
|
+
TrackProductListsOptions,
|
|
4
|
+
TrackPromotionsOptions,
|
|
5
|
+
TrackRecommendationRailsOptions,
|
|
6
|
+
} from './auto';
|
|
7
|
+
import type {
|
|
8
|
+
ConsentAdapter,
|
|
9
|
+
ConsentState,
|
|
10
|
+
CookieConsentOptions,
|
|
11
|
+
DidomiConsentOptions,
|
|
12
|
+
OneTrustConsentOptions,
|
|
13
|
+
} from './consent';
|
|
14
|
+
import type { DataLayerPreset } from './datalayer';
|
|
15
|
+
import type {
|
|
16
|
+
AnalyticsClient,
|
|
17
|
+
AnalyticsOptions,
|
|
18
|
+
CdpEvent,
|
|
19
|
+
PageType,
|
|
20
|
+
StorageNames,
|
|
21
|
+
} from './index';
|
|
22
|
+
|
|
23
|
+
/** Everything the page itself configures: the rest is the tenant's, and comes from settings. */
|
|
24
|
+
export interface HpConfig {
|
|
25
|
+
/** The source's publishable key, `hp_pk_{live|test}_…`. Safe in page source. */
|
|
26
|
+
writeKey: string;
|
|
27
|
+
/**
|
|
28
|
+
* Optional: only for a first-party proxy. Without it the SDK uses the HP API; the key selects the
|
|
29
|
+
* tenant, the property and test or live. `api.example.com` or `https://api.example.com`.
|
|
30
|
+
*/
|
|
31
|
+
apiHost?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Which CMP the page runs. Which purposes it reports comes from the settings. */
|
|
35
|
+
export type ConsentConfig =
|
|
36
|
+
| ({ provider: 'didomi' } & DidomiConsentOptions)
|
|
37
|
+
| ({ provider: 'onetrust' } & OneTrustConsentOptions)
|
|
38
|
+
| ({ provider: 'cookie' } & Partial<Omit<CookieConsentOptions, 'parse'>>)
|
|
39
|
+
| { provider: 'forwarded'; name?: string }
|
|
40
|
+
| { provider: 'static'; state: Partial<ConsentState> };
|
|
41
|
+
|
|
42
|
+
/** `GET {apiHost}/v1/projects/{writeKey}/settings`, the Segment-compatible settings response. */
|
|
43
|
+
export interface SettingsResponse {
|
|
44
|
+
integrations?: Record<string, unknown>;
|
|
45
|
+
plan?: { track?: Record<string, { enabled?: boolean }> };
|
|
46
|
+
hp?: {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
/** Null for a source scoped to several properties: the page passes `propertyId`. */
|
|
49
|
+
propertyId?: string | null;
|
|
50
|
+
environment?: 'production' | 'staging' | 'development';
|
|
51
|
+
/** Strict is the only mode. `purposes`: the ones this property asks for, and so reports. */
|
|
52
|
+
consent?: {
|
|
53
|
+
mode: 'strict';
|
|
54
|
+
purposes?: Partial<Record<keyof ConsentState, boolean>>;
|
|
55
|
+
};
|
|
56
|
+
queryParamAllowlist?: string[];
|
|
57
|
+
/** Host for ingest and for the API. */
|
|
58
|
+
apiHost?: string;
|
|
59
|
+
/** Each property's personalisation holdout, keyed by property id. */
|
|
60
|
+
holdouts?: Record<
|
|
61
|
+
string,
|
|
62
|
+
{ share: number; salt: string; readoutSlots?: string[] }
|
|
63
|
+
>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A dataLayer preset that may also supply consent adapter factories by provider name. */
|
|
68
|
+
export interface Preset extends DataLayerPreset {
|
|
69
|
+
consent?: Partial<
|
|
70
|
+
Record<
|
|
71
|
+
'didomi' | 'onetrust' | 'cookie' | 'static',
|
|
72
|
+
(options: Record<string, unknown>) => ConsentAdapter
|
|
73
|
+
>
|
|
74
|
+
>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The signed-in visitor, or a lookup of them: bootstrap waits for it alongside the settings. */
|
|
78
|
+
export interface Identity {
|
|
79
|
+
userId?: string | number | null;
|
|
80
|
+
emailSha256?: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AutocaptureOptions {
|
|
84
|
+
promotions?: boolean | TrackPromotionsOptions;
|
|
85
|
+
productLists?: boolean | TrackProductListsOptions;
|
|
86
|
+
recommendations?: boolean | TrackRecommendationRailsOptions;
|
|
87
|
+
/** `data-hp-exposure="<surface>"`: `surfaceViewed` once the element is viewable (3.15). */
|
|
88
|
+
exposures?: boolean | TrackExposuresOptions;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BootstrapOptions {
|
|
92
|
+
/** The page's CMP, as a provider name or an adapter of its own. Required: consent is strict. */
|
|
93
|
+
consent: ConsentConfig | ConsentAdapter;
|
|
94
|
+
/**
|
|
95
|
+
* The declarative trackers, on by default because they report only what the page tags:
|
|
96
|
+
* promotions (`data-hp-promotion`), product lists and clicks (`data-hp-list`, `data-hp-product`)
|
|
97
|
+
* and recommendation rails (`data-hp-reco-request`). `false` turns all off; per tracker, `false`
|
|
98
|
+
* or its options.
|
|
99
|
+
*/
|
|
100
|
+
autocapture?: false | AutocaptureOptions;
|
|
101
|
+
/**
|
|
102
|
+
* Redact or drop an event before it is sent: return it (changed or not), or null to drop it.
|
|
103
|
+
* Its type, name, id and time are kept. A hook that throws drops the event.
|
|
104
|
+
*/
|
|
105
|
+
beforeSend?(event: CdpEvent): CdpEvent | null;
|
|
106
|
+
/** Only for a key scoped to several properties, whose settings name none: which one this page is. */
|
|
107
|
+
propertyId?: string;
|
|
108
|
+
/** Domain for the consent cookie, e.g. '.example.com' to share it with checkout. */
|
|
109
|
+
consentCookieDomain?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The site's own first-party identity cookies. `sessionId` is always read from here; without it
|
|
112
|
+
* the SDK keeps its own session (3.15, see AnalyticsContextSources.getSessionId).
|
|
113
|
+
* `anonymousId` is read only when the source's settings name no device cookie of their own: a
|
|
114
|
+
* source set to the site's cookie without a name, or settings from before 3.6. Otherwise the
|
|
115
|
+
* settings decide (see AnalyticsOptions.anonymousIdSource). Without any: no anonymousId,
|
|
116
|
+
* identity_trust 'none'.
|
|
117
|
+
*/
|
|
118
|
+
identityCookies?: { anonymousId?: string; sessionId?: string };
|
|
119
|
+
/** See AnalyticsOptions.holdAcrossPages. Default false. */
|
|
120
|
+
holdAcrossPages?: boolean;
|
|
121
|
+
/** @deprecated Ignored since 3.6: see AnalyticsOptions.mintAnonymousId. */
|
|
122
|
+
mintAnonymousId?: boolean;
|
|
123
|
+
/** The signed-in visitor. userId goes on every event; the identify is sent once per session. */
|
|
124
|
+
identity?: Identity | Promise<Identity | null | undefined>;
|
|
125
|
+
/** Read per event: a site can trade in several currencies and ship anywhere. */
|
|
126
|
+
getCurrency?(): string | null | undefined;
|
|
127
|
+
getShipToCountry?(): string | null | undefined;
|
|
128
|
+
getLocale?(): string | null | undefined;
|
|
129
|
+
preset?: Preset;
|
|
130
|
+
pageType?: PageType;
|
|
131
|
+
/** Connect a GA4 dataLayer. `name` defaults to 'dataLayer'; `ignore` adds to the preset's. */
|
|
132
|
+
dataLayer?: { name?: string; ignore?: string[] };
|
|
133
|
+
/** Rename what the SDK stores. Defaults are `thg_hp_*`. */
|
|
134
|
+
storageNames?: StorageNames;
|
|
135
|
+
debug?: boolean;
|
|
136
|
+
/** Development and tests: use this settings response and never fetch or cache one. */
|
|
137
|
+
settings?: SettingsResponse;
|
|
138
|
+
/** createAnalytics options that are functions, e.g. getRecommendations or getExtensions. */
|
|
139
|
+
extend?: Partial<AnalyticsOptions>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The client, or null when the key is not enabled, the settings cannot be fetched on a first
|
|
144
|
+
* visit, or the options are invalid. Never rejects. Idempotent per page.
|
|
145
|
+
*/
|
|
146
|
+
export declare function bootstrap(
|
|
147
|
+
config: HpConfig,
|
|
148
|
+
options: BootstrapOptions,
|
|
149
|
+
): Promise<AnalyticsClient | null>;
|
|
150
|
+
/** Every reason the config and options cannot be used; empty when they can. */
|
|
151
|
+
export declare function validateConfig(
|
|
152
|
+
config: unknown,
|
|
153
|
+
options?: Partial<BootstrapOptions>,
|
|
154
|
+
): string[];
|