@thg-agentic/hyper-personalisation-web 3.19.0 → 3.22.2
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 +73 -27
- package/dist/hp.min.js +3 -3
- package/package.json +2 -2
- package/src/bootstrap.js +9 -3
- package/src/consent-gate.js +9 -1
- package/src/consent.js +23 -17
- package/src/context.js +17 -2
- package/src/index.js +91 -20
- package/src/segment.js +17 -9
- package/src/session/record.js +70 -10
- 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 +58 -8
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +1 -1
- package/types/events.d.ts +49 -49
- package/types/index.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @thg-agentic/hyper-personalisation-web
|
|
2
2
|
|
|
3
3
|
The browser SDK for THG Hyper-Personalisation, a customer data platform. It implements the event
|
|
4
|
-
specification (spec 1.
|
|
4
|
+
specification (spec 1.16, stamped on every event as `context.spec.version`) for any storefront: it
|
|
5
5
|
sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads
|
|
6
6
|
the GA4 `dataLayer` the site already pushes to, and has no runtime dependencies.
|
|
7
7
|
|
|
@@ -45,12 +45,12 @@ collects, is a settings change and never a deploy:
|
|
|
45
45
|
| From the settings (`hp`) | |
|
|
46
46
|
|---|---|
|
|
47
47
|
| `enabled` | Anything but `true` leaves the SDK off: no events, no cookie, nothing stored. |
|
|
48
|
-
| `propertyId` | `context.source.property_id
|
|
48
|
+
| `propertyId` | `context.source.property_id`: the source's default property. A `propertyId` the page passes wins over it (since 3.20); a key scoped to several properties with no default needs one. |
|
|
49
49
|
| `environment` | `context.source.environment`. |
|
|
50
50
|
| `consent.purposes` | The purposes the property asks for. Only those are reported; the rest read as unanswered. Strict is the only mode. |
|
|
51
51
|
| `queryParamAllowlist` | Query parameters kept on page URLs (plus campaign parameters). |
|
|
52
52
|
| `anonymousId` | Where device ids come from, chosen per source in the console (since 3.6): `{ source: 'site_cookie', cookie }` or `{ source: 'hp_cookie' }`. See [Device ids](#device-ids). |
|
|
53
|
-
| `apiHost` | Where events go
|
|
53
|
+
| `apiHost` | Where events go when the page set no `apiHost` (an `https://` host only). A page's own `apiHost` is a proxy and carries events too (since 3.20). |
|
|
54
54
|
| `plan.track` | Segment's tracking plan: an event with `enabled: false` is dropped at the source. The console's blocked events arrive here. |
|
|
55
55
|
| `sampleRate` | The share of visitors events are sent for, by a hash of the anonymous id (since 3.18). Absent: 1. |
|
|
56
56
|
| `debug` | `{ enabled, tokenSha256 }`: console logging for everyone, or for a session opened with `?hp_debug=<token>` whose SHA-256 is `tokenSha256` (since 3.18). `?hp_debug=` ends it. |
|
|
@@ -85,7 +85,10 @@ What `bootstrap` does:
|
|
|
85
85
|
and an `ETag`, so the browser revalidates for a `304`). A key the API switches off clears the cache, so the next page is
|
|
86
86
|
off too.
|
|
87
87
|
- **Consent**, identity, one page view, the dataLayer bridge and a once-per-session `identify`, as
|
|
88
|
-
configured.
|
|
88
|
+
configured. A page restored from the back/forward cache runs no script again, so `bootstrap`
|
|
89
|
+
reads consent again, then sends its `back_forward` page view on `pageshow` and reports its lists
|
|
90
|
+
afresh (3.22.2). A host on `createAnalytics` sends that view itself: `page({ navigation_trigger:
|
|
91
|
+
'back_forward' })` on a `pageshow` whose `persisted` is true.
|
|
89
92
|
- **Never waited for.** Call `hp` (`@thg-agentic/hyper-personalisation-web/hp`) from anywhere, at
|
|
90
93
|
any time, and never await or flush it: calls made before the client starts wait, each with the
|
|
91
94
|
messageId and time it was made at, and are sent in order once it has. The page view is stamped
|
|
@@ -189,7 +192,7 @@ Since 3.19 (spec 1.12), a search box's suggestions are their own event:
|
|
|
189
192
|
|
|
190
193
|
| Option | Default | |
|
|
191
194
|
|---|---|---|
|
|
192
|
-
| `apiHost` | the HP API | Only for a first-party proxy.
|
|
195
|
+
| `apiHost` | the HP API | Only for a first-party proxy. The settings and the events both go to it: events are POSTed to `${apiHost}/v1/batch` (since 3.20 a host in the settings never overrides it). |
|
|
193
196
|
| `writeKey` | `'web'` | The source's publishable key, `hp_pk_…`. Sent in the batch body. |
|
|
194
197
|
| `propertyId` | required | `context.source.property_id`. |
|
|
195
198
|
| `channel`, `market`, `environment` | `null` | The rest of `context.source`. |
|
|
@@ -204,10 +207,10 @@ Since 3.19 (spec 1.12), a search box's suggestions are their own event:
|
|
|
204
207
|
| `storageNames` | `thg_hp_*` | Rename what the SDK stores; see [Storage](#storage). |
|
|
205
208
|
| `plan` | none | A Segment tracking plan, `{ track: { [event]: { enabled } } }`; `__default` covers unlisted events. |
|
|
206
209
|
| `sampleRate` | `1` | The share of visitors to send for, by a hash of the anonymous id salted `hp_sample`; a visitor with no anonymous id is sent for. A consent withdrawal is always sent. Since 3.18. |
|
|
207
|
-
| `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page
|
|
210
|
+
| `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`, and in a clicked link's `href`, which leaves another site's link as origin and path only (3.22.2). Patterns are anchored and case-insensitive. |
|
|
208
211
|
| `queryParamBlocklist` | `[]` | Strip these params. Ignored when an allowlist is given. |
|
|
209
212
|
| `sensitiveParamAllowlist` | `[]` | Whatever else is configured, a param is always dropped from every reported URL when its name contains `email`, `token`, `password`, `secret`, `auth`, `session`, `phone`, `address`, `card`, `customerid` and the like (see `SENSITIVE_PARAM_FRAGMENTS` in `src/context.js`), or when its value is an email address or a card-length number — campaign params and the referrer included. This exempts names from the first rule, for a false positive such as an `author` filter; nothing exempts a value. |
|
|
210
|
-
| `getAnonymousId`, `getUserId`, `getSessionId
|
|
213
|
+
| `getAnonymousId`, `getUserId`, `getSessionId` | none | Identity accessors. Without `getSessionId` the SDK keeps its own session (see [Session ids](#session-ids)); a missing or empty device id gives way to the SDK's own (see [Device ids](#device-ids)). |
|
|
211
214
|
| `getExperiments`, `getAudiences`, `getDecisions`, `getRecommendations`, `getExtensions` | none | Read per event into the matching `context` field. Without `getDecisions`, `context.decisions` is what `recordExposure` last recorded per slot. |
|
|
212
215
|
| `pageType` | `null` | `context.page.type` before the first `page()` call. |
|
|
213
216
|
| `onEvent` | none | Observer for every event that will be sent. |
|
|
@@ -230,7 +233,7 @@ Declarations are in `types/`, and event properties are typed from the registry.
|
|
|
230
233
|
- **One event is at most 55 KB** of JSON (3.19), so that it can always travel in a keepalive request
|
|
231
234
|
at unload. A bigger one is not sent and its method returns null; with `debug` the SDK logs it.
|
|
232
235
|
Segment's limit is 32 KB; this one is higher so that a long product list still fits.
|
|
233
|
-
- **A sign-out forgets the visit** (3.19). `signedOut()`, or `reset()` while a user is signed in,
|
|
236
|
+
- **A sign-out forgets the visit** (3.19). `signedOut()` (or `track('Signed Out')`, since 3.20), or `reset()` while a user is signed in,
|
|
234
237
|
also forgets the SDK's session, the decisions in force and the recommendation context, so the
|
|
235
238
|
next person on a shared device inherits none of them. `reset()` with nobody signed in changes
|
|
236
239
|
nothing else, so a host that calls it on every page keeps its session. The SDK's own device id
|
|
@@ -245,9 +248,22 @@ Declarations are in `types/`, and event properties are typed from the registry.
|
|
|
245
248
|
`Consent Updated` event. Before the client starts, `hp.recommendationContext()` reads the kept
|
|
246
249
|
visit only when the consent cookie grants personalisation.
|
|
247
250
|
- **Retries wait out `Retry-After`** on a 429 or 5xx (3.19), as well as backing off exponentially
|
|
248
|
-
with jitter, up to 30 seconds.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
with jitter, up to 30 seconds. Cross-origin, the HP ingest exposes the header to the page
|
|
252
|
+
(`Access-Control-Expose-Headers: Retry-After`, from the `ingest-gateway-v1.13.0` release);
|
|
253
|
+
a same-origin proxy needs nothing. Where the header cannot be read the backoff alone applies.
|
|
254
|
+
- **A refusal sends only the withdrawal** (3.20): what was collected under the grant and not yet sent
|
|
255
|
+
is dropped, including batches already cut while one was on the wire.
|
|
256
|
+
- **`identify` sends only the spec's traits** (3.20): `email_sha256` and `phone_sha256` (SHA-256 hex,
|
|
257
|
+
or base64 sent as hex), `accepts_marketing` (boolean), `gender` (string) and `created_at` (RFC 3339
|
|
258
|
+
date-time). Anything else, a raw `email` or `phone` included, is not sent; with `debug` the dropped
|
|
259
|
+
keys are logged.
|
|
260
|
+
- **A listing must name its own `category`** (3.22): a Product List Viewed without one, and its
|
|
261
|
+
facets, no longer count as browsing (`listViews`, `browseViews`, `browseShare`), with or without
|
|
262
|
+
`browse_categories`, and product views after it credit no category page. Send `category` on every
|
|
263
|
+
category listing. See [Visit traits](#visit-traits).
|
|
264
|
+
- **The tracking plan covers every call** (3.20): it is keyed as the gateway keys it, a track's
|
|
265
|
+
event name or otherwise the call's type (`page`, `identify`), so an allow-list that omits `page`
|
|
266
|
+
sends no page views. A consent withdrawal is sent whatever the plan says.
|
|
251
267
|
|
|
252
268
|
## Calling the serving API
|
|
253
269
|
|
|
@@ -316,6 +332,11 @@ render(body);
|
|
|
316
332
|
hp.recordExposure(body);
|
|
317
333
|
```
|
|
318
334
|
|
|
335
|
+
For a decisions response the exposure lists each slot's `decision_id`, which is what marks that
|
|
336
|
+
decision shown (a click is a `Slot Engaged` with `engagement: 'click'` and the same `decision_id`).
|
|
337
|
+
It is one event per response, so record it once the slots are rendered: a slot rendered below the
|
|
338
|
+
fold counts as shown.
|
|
339
|
+
|
|
319
340
|
Since 3.7, recording a decisions response also puts its decisions in force. Every later event in
|
|
320
341
|
the browser session carries them as `context.decisions`, latest per slot, so a basket is credited
|
|
321
342
|
to the slot shown before it. Send the session id your events carry as `session_id` on
|
|
@@ -325,13 +346,7 @@ to the slot shown before it. Send the session id your events carry as `session_i
|
|
|
325
346
|
### Server-side assignments
|
|
326
347
|
|
|
327
348
|
A page your server rendered from `POST /v1/assignments` and cached per arm has no `decision_id` in
|
|
328
|
-
it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously
|
|
329
|
-
for that page's `Slot Engaged`:
|
|
330
|
-
|
|
331
|
-
```js
|
|
332
|
-
const decisionId = hp.assignmentDecisionId({ slot, arm });
|
|
333
|
-
if (decisionId) hp.slotEngaged({ slot, arm, engagement: 'view', decision_id: decisionId });
|
|
334
|
-
```
|
|
349
|
+
it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously.
|
|
335
350
|
|
|
336
351
|
It reads the session cookie named in `identityCookies.sessionId`, else the visitor cookie
|
|
337
352
|
(`identityCookies.anonymousId`), as your renderer forwards them to HP, so it answers once
|
|
@@ -340,20 +355,28 @@ with neither cookie. The id is `UUIDv5(ASSIGNMENT_DECISION_NAMESPACE, "<session>
|
|
|
340
355
|
`assignmentDecisionId({ sessionId, slot, arm })` from the main entry computes it from values you
|
|
341
356
|
pass, and the spec's `assignment/decision-id.json` has the vectors.
|
|
342
357
|
|
|
343
|
-
Such a page
|
|
344
|
-
|
|
345
|
-
the `""` one); on any other page with
|
|
346
|
-
|
|
347
|
-
|
|
358
|
+
Such a page reports the property holdout's exposure itself, when the slot is on screen, for both
|
|
359
|
+
groups, and a `Slot Engaged` click when a link in the slot is clicked. The answer's `hash` is
|
|
360
|
+
`holdout` for a held-out visitor (cache that page apart from the `""` one); on any other page with
|
|
361
|
+
a hash the visitor was treated. The exposure token is the assignment's derived id for the arm
|
|
362
|
+
shown, control included, and a held-out visitor's exposure names no decision. Since 3.13 the shares
|
|
363
|
+
come from the property's settings when the exposure has none:
|
|
348
364
|
|
|
349
365
|
```js
|
|
350
366
|
const token = hp.assignmentDecisionId({ slot, arm });
|
|
367
|
+
const holdout = hash === 'holdout';
|
|
368
|
+
// When the slot is on screen:
|
|
351
369
|
hp.recordExposure({
|
|
352
370
|
exposure: { token, surface: 'decisions', holdout },
|
|
353
371
|
decisions: [holdout ? { slot, holdout: true } : { slot, arm, decision_id: token }],
|
|
354
372
|
});
|
|
373
|
+
// When a link in the slot is clicked:
|
|
374
|
+
if (token && !holdout) hp.slotEngaged({ slot, arm, engagement: 'click', decision_id: token });
|
|
355
375
|
```
|
|
356
376
|
|
|
377
|
+
The exposure is what marks the decision shown; the click is its reward. Slot Engaged's `view` is
|
|
378
|
+
deprecated since spec 1.16: it repeated the exposure.
|
|
379
|
+
|
|
357
380
|
A surface your server fetched from HP for the page, so the page never sees HP's answer, reports
|
|
358
381
|
its exposure with `surfaceViewed` when it comes into view: a product rail your server asked HP for,
|
|
359
382
|
which HP answers with its rail for a treated visitor and the site's own rail for a held-out one.
|
|
@@ -407,6 +430,25 @@ A session audience's rules name fields the SDK keeps for the visit, from the eve
|
|
|
407
430
|
`categoryViews.<path>`, `categoryShare.<path>` and, since 3.18, `basketValue`. A field this build
|
|
408
431
|
does not know fails its rule closed.
|
|
409
432
|
|
|
433
|
+
`listViews`, `browseViews` and `browseShare` count category pages, as the warehouse does. A listing
|
|
434
|
+
must send its own `category` (Product List Viewed); one without is not recorded. Since 3.22, pass
|
|
435
|
+
the datafile's `browse_categories` as the fifth argument to `resolveAudiences` or
|
|
436
|
+
`resolveAudienceMemberships`, and a listing counts only when the catalogue files products under its
|
|
437
|
+
`category`, so a merchandising list (best sellers, new in, a sale) is never browsing interest:
|
|
438
|
+
|
|
439
|
+
```js
|
|
440
|
+
resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for, datafile.browse_categories);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Each trail entry is judged on the listing it came from, as the warehouse judges each event: two
|
|
444
|
+
listings under one path, one a category and one a sale list, count once. Product views and adds
|
|
445
|
+
credit the category page they were reached from, and a facet (Product List Filtered) counts only on
|
|
446
|
+
the listing being browsed; the warehouse counts neither, so these two measures run a little ahead
|
|
447
|
+
of it. Without `browse_categories` (a datafile that predates it, or a property whose catalogue is
|
|
448
|
+
not served yet, when the field is absent), every listing that names a category counts. A visit
|
|
449
|
+
recorded by an SDK before 3.22 has no listing categories, so with the list its earlier browsing
|
|
450
|
+
drops out once.
|
|
451
|
+
|
|
410
452
|
`basketValue` is the `value` of the last `cartUpdated` (Cart Updated), in that event's currency:
|
|
411
453
|
the basket as your platform priced it. It is never summed from `productAdded` lines, whose prices
|
|
412
454
|
can include tax where `value` does not. It is `null`, so every rule on it fails, until the visit
|
|
@@ -420,7 +462,7 @@ response's `audiences` and `qualified_for`, and the datafile's `session_audience
|
|
|
420
462
|
`resolveAudienceMemberships` exactly as before:
|
|
421
463
|
|
|
422
464
|
```js
|
|
423
|
-
const memberships = resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for);
|
|
465
|
+
const memberships = resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for, datafile.browse_categories);
|
|
424
466
|
```
|
|
425
467
|
|
|
426
468
|
A targetable visit audience may require an audience nobody may target. The server sends that base
|
|
@@ -471,6 +513,10 @@ With a `consent` adapter the SDK is strict:
|
|
|
471
513
|
- **Changes nothing announces:** after the first answer, an adapter whose `subscribe` cannot see
|
|
472
514
|
every change (a cookie adapter on a browser without the Cookie Store API, or your own adapter
|
|
473
515
|
without `observesChanges: true`) is re-read every 2 seconds while the page is visible.
|
|
516
|
+
- **Decisions made in another tab:** a CMP reports a change only in the tab where it was made
|
|
517
|
+
(OneTrust's `OnConsentChanged`, Didomi's `consent.changed`). Another tab already open applies
|
|
518
|
+
it at its next page load, as Segment's, RudderStack's and mParticle's OneTrust integrations and
|
|
519
|
+
Google Consent Mode do.
|
|
474
520
|
|
|
475
521
|
- **Purposes the property does not ask for:** with the settings' `consent.purposes`, only the
|
|
476
522
|
purposes listed as `true` are reported (analytics always is). The others read as unanswered in
|
|
@@ -495,7 +541,7 @@ All adapters are imported from `@thg-agentic/hyper-personalisation-web/consent`.
|
|
|
495
541
|
| Adapter | Reads | Not answered while |
|
|
496
542
|
|---|---|---|
|
|
497
543
|
| `didomiConsent({ purposes })` | `Didomi.getCurrentUserStatus().purposes`, subscribed via `didomiEventListeners` / `consent.changed` | Didomi is not loaded, `shouldUserStatusBeCollected()` is true, or `isUserStatusPartial()` is true |
|
|
498
|
-
| `oneTrustConsent({ groups, impliedConsent })` |
|
|
544
|
+
| `oneTrustConsent({ groups, impliedConsent })` | The `OptanonConsent` cookie's `groups=`, else `OnetrustActiveGroups` before OneTrust has written it; subscribed via `OneTrustGroupsUpdated`, `OptanonWrapper` and `OneTrust.OnConsentChanged` | The banner was never closed (`OptanonAlertBoxClosed`) and never interacted with (`interactionCount`) |
|
|
499
545
|
| `cookieConsent({ name, categories, parse, refusedValue, source, changeEvent })` | A first-party cookie your banner writes; subscribed via a `thg_hp_consent_change` window event (`changeEvent`) and Cookie Store `change` | The cookie is absent, or its value cannot be parsed |
|
|
500
546
|
| `staticConsent(state)` | The state you pass, for sites with no CMP | Never |
|
|
501
547
|
|
|
@@ -855,8 +901,8 @@ memory), and a refusal deletes it. Before 3.15 such a site sent `session_id: nul
|
|
|
855
901
|
|
|
856
902
|
## CSP and proxying
|
|
857
903
|
|
|
858
|
-
The SDK makes requests only to `apiHost` (the settings
|
|
859
|
-
|
|
904
|
+
The SDK makes requests only to `apiHost` (the settings and the events; without an `apiHost`, events
|
|
905
|
+
go to the settings' host when they name one), so allow it in `connect-src`: `resolveApiHost(apiHost)` is that origin, the HP API
|
|
860
906
|
(`DEFAULT_API_HOST`) when you set none. Since 3.18 that is `https://hp.thgingenuity.com`
|
|
861
907
|
(`connect-src https://hp.thgingenuity.com`); 3.17 used HP's nonprod host and 3.16 and earlier a
|
|
862
908
|
staging host that is being retired. Allow the CDN host in `script-src` too if you use the script. Call the API host directly rather than through a CDN proxy route: behind a proxy the ingest
|