@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/README.md
CHANGED
|
@@ -1,3 +1,861 @@
|
|
|
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.10, stamped on every event as `context.spec.version`) for any storefront: it
|
|
5
|
+
sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads
|
|
6
|
+
the GA4 `dataLayer` the site already pushes to, and has no runtime dependencies.
|
|
7
|
+
|
|
8
|
+
The core knows standards only: GA4 and Segment. A **preset** teaches it one commerce platform's
|
|
9
|
+
conventions; none ships with the SDK, and you can write your own in about 20 lines.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i @thg-agentic/hyper-personalisation-web
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or, without a bundler, the [CDN script](#cdn-script).
|
|
16
|
+
|
|
17
|
+
## Integrating in one call
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { bootstrap } from '@thg-agentic/hyper-personalisation-web/bootstrap';
|
|
21
|
+
|
|
22
|
+
const hp = await bootstrap(
|
|
23
|
+
{ writeKey: 'hp_pk_live_…' },
|
|
24
|
+
{
|
|
25
|
+
consent: { provider: 'onetrust' },
|
|
26
|
+
identityCookies: { sessionId: 'session_id' },
|
|
27
|
+
getCurrency: () => getCookie('currency'),
|
|
28
|
+
pageType: 'product',
|
|
29
|
+
identity: { userId: customer?.id, emailSha256: customer?.emailSha256 },
|
|
30
|
+
dataLayer: {},
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**The site configures one value: the key.** `writeKey` is the source's publishable key
|
|
36
|
+
(`hp_pk_live_…` in production, `hp_pk_test_…` elsewhere), safe in page source. It names the
|
|
37
|
+
organisation, the property and test or live, so there is no host, property or switch to set per
|
|
38
|
+
environment, as with Stripe's or LaunchDarkly's keys. `apiHost` is optional, only for a first-party
|
|
39
|
+
proxy (a bare host or an `https://` origin); without it the SDK uses the HP API
|
|
40
|
+
(`DEFAULT_API_HOST`, `https://hp.thgingenuity.com` since 3.18, for test and live keys alike; see
|
|
41
|
+
[Default host](#default-host-by-release)). Everything tenant-level comes from
|
|
42
|
+
`GET {apiHost}/v1/projects/{writeKey}/settings`, so switching a site on or off, or changing what it
|
|
43
|
+
collects, is a settings change and never a deploy:
|
|
44
|
+
|
|
45
|
+
| From the settings (`hp`) | |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `enabled` | Anything but `true` leaves the SDK off: no events, no cookie, nothing stored. |
|
|
48
|
+
| `propertyId` | `context.source.property_id`. Null for a key scoped to several properties; the page then passes `propertyId`. |
|
|
49
|
+
| `environment` | `context.source.environment`. |
|
|
50
|
+
| `consent.purposes` | The purposes the property asks for. Only those are reported; the rest read as unanswered. Strict is the only mode. |
|
|
51
|
+
| `queryParamAllowlist` | Query parameters kept on page URLs (plus campaign parameters). |
|
|
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, when not the host the settings came from. |
|
|
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
|
+
| `sampleRate` | The share of visitors events are sent for, by a hash of the anonymous id (since 3.18). Absent: 1. |
|
|
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. |
|
|
57
|
+
|
|
58
|
+
**The page configures only what the page knows** (the second argument):
|
|
59
|
+
|
|
60
|
+
| Option | |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `consent` | **Required.** The page's CMP: `{ provider: 'didomi' \| 'onetrust' \| 'cookie' \| 'forwarded' \| 'static', ...adapterOptions }`, or a consent adapter of your own. A preset may supply defaults for a provider. |
|
|
63
|
+
| `identityCookies` | `{ anonymousId, sessionId }`: the names of your own first-party device and session cookies. `sessionId` is always read from here; without it the SDK keeps its own session (see [Session ids](#session-ids)). `anonymousId` is read only when the settings name no device cookie: see [Device ids](#device-ids). |
|
|
64
|
+
| `autocapture` | `false`, or per tracker `{ promotions, productLists, recommendations, exposures }`, each `false` or its options. All on by default: they report only what the page tags. |
|
|
65
|
+
| `identity` | The signed-in visitor, `{ userId, emailSha256 }`. `userId` goes on every event; the `identify` is sent once per session and user. |
|
|
66
|
+
| `getCurrency`, `getShipToCountry`, `getLocale` | Read per event; see below. |
|
|
67
|
+
| `preset` | A platform preset, passed in by the host. |
|
|
68
|
+
| `pageType` | `context.page.type` of this page's page view. |
|
|
69
|
+
| `dataLayer` | `{ name?, ignore? }`: connect `window[name ?? 'dataLayer']` through the preset's mapping. Absent: no bridge. |
|
|
70
|
+
| `propertyId` | Only for a key scoped to several properties, whose settings name none. |
|
|
71
|
+
| `consentCookieDomain` | `Domain` for the consent cookie, e.g. `.acme.com` to share it with checkout. |
|
|
72
|
+
| `storageNames` | Rename what the SDK stores; see [Storage](#storage). |
|
|
73
|
+
| `debug` | Log every event and delivery problem. Also on when the settings' `debug` says so. |
|
|
74
|
+
| `settings` | Development and tests only: use this settings response, never fetch or cache one. |
|
|
75
|
+
| `extend` | `createAnalytics` options that are functions, such as `getRecommendations` or `getExtensions`. |
|
|
76
|
+
|
|
77
|
+
What `bootstrap` does:
|
|
78
|
+
|
|
79
|
+
- **Resolves to the client**, or `null` when the key is not enabled, the settings cannot be fetched
|
|
80
|
+
on a first visit, or the options are invalid. Invalid options log one console warning listing
|
|
81
|
+
every problem. It never rejects, and a second call on the same page returns the first client.
|
|
82
|
+
- **Settings.** The last good answer is cached in `localStorage`, only once analytics is granted,
|
|
83
|
+
and forgotten on a refusal. A page with a cache starts from it at once and asks the API again for
|
|
84
|
+
the next page; a first visit waits for the API (which answers `Cache-Control: max-age=10`
|
|
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
|
+
off too.
|
|
87
|
+
- **Consent**, identity, one page view, the dataLayer bridge and a once-per-session `identify`, as
|
|
88
|
+
configured.
|
|
89
|
+
- **Never waited for.** Call `hp` (`@thg-agentic/hyper-personalisation-web/hp`) from anywhere, at
|
|
90
|
+
any time, and never await or flush it: calls made before the client starts wait, each with the
|
|
91
|
+
messageId and time it was made at, and are sent in order once it has. The page view is stamped
|
|
92
|
+
when the page was navigated to, so none of them precedes it (3.18). A page that navigates
|
|
93
|
+
first (a sign-in that redirects at once) carries them in sessionStorage, and the next page's
|
|
94
|
+
`bootstrap` sends them once, before its own page view (since 3.9). So does a page whose settings
|
|
95
|
+
request never answered: the API down, or the request cancelled by the navigation (3.9.1). The carry is a device write,
|
|
96
|
+
so it is made only once this device has granted analytics, or unanswered with `holdAcrossPages`;
|
|
97
|
+
the next page's consent decides whether they are sent, and a refusal drops them. Once started,
|
|
98
|
+
the SDK hands its queue to `sendBeacon` at `pagehide` itself.
|
|
99
|
+
|
|
100
|
+
## Multi-currency and multi-destination tenants
|
|
101
|
+
|
|
102
|
+
One property can trade in several currencies and ship to many countries: a visitor on one site can
|
|
103
|
+
pick EUR and a German delivery address halfway through a visit. So `context.currency`,
|
|
104
|
+
`context.ship_to_country` and `context.locale` are read **per event**, never fixed per tenant.
|
|
105
|
+
|
|
106
|
+
- **Getters.** Pass `getCurrency`, `getShipToCountry` and `getLocale` (to `bootstrap`,
|
|
107
|
+
`createAnalytics` or `cdpPlugins`). They are called for every event. `createAnalytics` and
|
|
108
|
+
`cdpPlugins` also take static `currency` / `shipToCountry` / `locale`, which a getter wins over
|
|
109
|
+
whenever it returns a value.
|
|
110
|
+
- **Validation.** Values are uppercased and validated on read: currency `^[A-Z]{3}$`, ship-to
|
|
111
|
+
country `^[A-Z]{2}$`, locale a BCP-47 tag such as `en-GB` (a platform's `en_GB` is normalised to
|
|
112
|
+
`en-GB`). An invalid value is dropped (sent as null), never emitted, so it cannot quarantine the
|
|
113
|
+
event.
|
|
114
|
+
- **Prices keep their own currency.** A GA4 push carries the currency its prices are in.
|
|
115
|
+
- Cart Viewed and Checkout Started take it as `properties.currency`.
|
|
116
|
+
- Every event mapped from that push also gets it as its `context.currency`, so a price pushed
|
|
117
|
+
in EUR is never stamped with the site's current GBP.
|
|
118
|
+
- A push with no currency falls back to the site's current one.
|
|
119
|
+
- **Country is not here.** `context.country` (where the visitor is) is derived at the edge, so
|
|
120
|
+
there is deliberately no `getCountry`.
|
|
121
|
+
|
|
122
|
+
## The lower-level API
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
import { createAnalytics, getCookie, hashEmail } from '@thg-agentic/hyper-personalisation-web';
|
|
126
|
+
import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
|
|
127
|
+
import { connectDataLayer } from '@thg-agentic/hyper-personalisation-web/datalayer';
|
|
128
|
+
|
|
129
|
+
const analytics = createAnalytics({
|
|
130
|
+
writeKey: 'hp_pk_live_…',
|
|
131
|
+
propertyId: 'acme-gb', // stable id of this site
|
|
132
|
+
currency: 'GBP',
|
|
133
|
+
locale: 'en-GB', // the site's locale, not the browser's
|
|
134
|
+
consent: didomiConsent(), // strict: nothing is sent until analytics is granted
|
|
135
|
+
consentCookieDomain: '.acme.com',
|
|
136
|
+
queryParamAllowlist: ['colour', 'size', 'sort'],
|
|
137
|
+
getAnonymousId: () => getCookie('device_id'),
|
|
138
|
+
getSessionId: () => getCookie('session_id'),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
analytics.page({ type: 'product' });
|
|
142
|
+
connectDataLayer(analytics); // every GA4 ecommerce push becomes a spec event
|
|
143
|
+
analytics.identify({ userId: customer.id, email_sha256: await hashEmail(customer.email) });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`createAnalytics` takes everything as options and fetches nothing: it is what `bootstrap` calls once
|
|
147
|
+
the settings have answered. Add the named methods (`analytics.productViewed(...)` and so on)
|
|
148
|
+
wherever a page has something the dataLayer does not.
|
|
149
|
+
|
|
150
|
+
Since 3.1 (spec 1.2) there is a named method for every Segment Ecommerce V2 event, including the
|
|
151
|
+
five spec 1.2 restored:
|
|
152
|
+
|
|
153
|
+
| Method | Event | Notes |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `wishlistProductAddedToCart` | Wishlist Product Added to Cart | A wishlist conversion. Call `productAdded` (with `surface: 'wishlist'`) for the cart add as well: every cart-add metric reads Product Added |
|
|
156
|
+
| `productShared` / `cartShared` | Product Shared / Cart Shared | `share_message` and `recipient` are personal data; the ingest splits them off before anything is stored |
|
|
157
|
+
| `couponEntered` | Coupon Entered | The attempt, before the outcome. The code goes in `coupon_name` (Segment's `coupon_id` is accepted too) |
|
|
158
|
+
| `couponApplied` / `couponDenied` / `couponRemoved` | Coupon Applied / Denied / Removed | Sendable from the browser since spec 1.2. Report them from ONE producer per property, the storefront or the service that handles the code, or every code is counted twice |
|
|
159
|
+
|
|
160
|
+
Since 3.5 (spec 1.4):
|
|
161
|
+
|
|
162
|
+
| Method | Event | Notes |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `cartUpdated` | Cart Updated | Pass the basket your cart API returned after the change: every line with its TOTAL quantity, `value` excluding tax, the codes, a `reason` and an `initiator`. Send it alongside `productAdded` / `productRemoved`, never instead. `item_count` is summed from the lines when omitted; `value` is never derived, because line prices include tax where the market shows them so |
|
|
165
|
+
| `productVariantSelected` | Product Variant Selected | On the shopper's pick of a size, colour or flavour, not on page load. `product_id` the master, `sku` the variant now selected, `changed_attributes` what changed |
|
|
166
|
+
| `backInStockSubscribed` | Back In Stock Subscribed | `kind` back_in_stock, price_drop or launch, for one product (the out-of-stock `sku`). Hash any email or phone with `hashEmail()` / `hashPhone()` |
|
|
167
|
+
| `newsletterUnsubscribed` | Newsletter Unsubscribed | An opt-out made on the site. The ESP reports its own opt-outs as Email Unsubscribed |
|
|
168
|
+
| `checkoutFailed` | Checkout Failed | `stage` and `reason_kind` are required. `payment_method` is the kind of instrument, never card data |
|
|
169
|
+
|
|
170
|
+
`identityLinked` and `slotEngaged` complete the set: every spec event a browser may send has a
|
|
171
|
+
named method, and `hp.track('<Spec Event>', …)` is typed by the event's schema as the client's
|
|
172
|
+
`track` is.
|
|
173
|
+
|
|
174
|
+
New optional fields on existing methods: `search_request_id` on `productsSearched`, `productClicked`
|
|
175
|
+
and `productAdded` (your search engine's id for the result set, such as Algolia's `queryID`);
|
|
176
|
+
`initiator` (`shopper` or `system`) on `productAdded` and `productRemoved`;
|
|
177
|
+
`products[].subscription` on basket and order lines; `channel` and `phone_sha256` on
|
|
178
|
+
`newsletterSubscribed`. A search page sends `productsSearched` with its results and never a
|
|
179
|
+
`productListViewed` for the same grid.
|
|
180
|
+
|
|
181
|
+
## Options
|
|
182
|
+
|
|
183
|
+
| Option | Default | |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `apiHost` | the HP API | Only for a first-party proxy. Events are POSTed to `${apiHost}/v1/batch`. |
|
|
186
|
+
| `writeKey` | `'web'` | The source's publishable key, `hp_pk_…`. Sent in the batch body. |
|
|
187
|
+
| `propertyId` | required | `context.source.property_id`. |
|
|
188
|
+
| `channel`, `market`, `environment` | `null` | The rest of `context.source`. |
|
|
189
|
+
| `currency`, `getCurrency` | `null` | ISO-4217 currency of this request; the getter is read per event and wins. A per-call override wins over both. |
|
|
190
|
+
| `shipToCountry`, `getShipToCountry` | `null` | ISO-3166 alpha-2 destination, per event. |
|
|
191
|
+
| `locale`, `getLocale` | `null` | The storefront locale served (BCP-47), per event. |
|
|
192
|
+
| `country` | `null` | Visitor country; the ingest derives it at the edge and overwrites this. |
|
|
193
|
+
| `consent` | none | A consent adapter (see below). Makes the SDK strict. |
|
|
194
|
+
| `consentTimeoutMs` | `5000` | How long events wait for the CMP before they are dropped. |
|
|
195
|
+
| `consentCookieDomain` | host only | `Domain` for the consent cookie. |
|
|
196
|
+
| `onConsentStatus` | none | Called with the gate's status: `granted`, `denied` or `unknown` (timed out). |
|
|
197
|
+
| `storageNames` | `thg_hp_*` | Rename what the SDK stores; see [Storage](#storage). |
|
|
198
|
+
| `plan` | none | A Segment tracking plan, `{ track: { [event]: { enabled } } }`; `__default` covers unlisted events. |
|
|
199
|
+
| `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. |
|
|
200
|
+
| `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`. Patterns are anchored and case-insensitive. |
|
|
201
|
+
| `queryParamBlocklist` | `[]` | Strip these params. Ignored when an allowlist is given. |
|
|
202
|
+
| `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. |
|
|
203
|
+
| `getAnonymousId`, `getUserId`, `getSessionId`, `getRenderId` | 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)). |
|
|
204
|
+
| `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. |
|
|
205
|
+
| `pageType` | `null` | `context.page.type` before the first `page()` call. |
|
|
206
|
+
| `onEvent` | none | Observer for every event that will be sent. |
|
|
207
|
+
| `debug` | `false` | Logs every event and delivery problem to the console. |
|
|
208
|
+
| `validate`, `throwOnInvalid` | none | Inject `validateEvent` from `@thg-agentic/hyper-personalisation-spec` in development. |
|
|
209
|
+
| `transport` | built in | Replace delivery, for example in tests. |
|
|
210
|
+
|
|
211
|
+
The client has one method per browser event in the spec, plus `track`, `page`, `identify`,
|
|
212
|
+
`reset`, `signedOut`, `flush`, `servingHeaders`, and the read-only `consent` and
|
|
213
|
+
`lastPageMessageId`. Every method takes an optional last argument of context overrides, either as
|
|
214
|
+
the overrides or as `{ context }`. An event method returns the event, or null when the tracking
|
|
215
|
+
plan disables it. Declarations are in `types/`, and event properties are typed from the registry.
|
|
216
|
+
|
|
217
|
+
## Calling the serving API
|
|
218
|
+
|
|
219
|
+
Recommendations, decisions and audience membership personalise only for a visitor who granted
|
|
220
|
+
personalisation. Every request to them carries the visitor's consent in the `hp-consent` header,
|
|
221
|
+
in the consent cookie's serialisation (`a=1&p=0`):
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
const res = await fetch(`https://api.example.com/v1/recommendations?placement=pdp`, {
|
|
225
|
+
headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Without `p=1` the API answers with the non-personalised response. `servingHeaders(state)` from
|
|
230
|
+
`./consent` builds the header from any consent state.
|
|
231
|
+
|
|
232
|
+
### The visit, for a rail that reacts to it
|
|
233
|
+
|
|
234
|
+
Since 3.11, `hp.recommendationContext()` returns what this visitor did earlier in the visit, as the
|
|
235
|
+
recommendations API's session parameters: `viewed` (products viewed, clicked or added), `basket`,
|
|
236
|
+
`q` (searches) and `impressed` (products shown and not engaged with, as `id:count`), newest first
|
|
237
|
+
and within the API's limits. Append it to the rail request, or send it to the server that makes it:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
const visit = new URLSearchParams(hp.recommendationContext());
|
|
241
|
+
const res = await fetch(`https://api.example.com/v1/recommendations?recipe=similar_items&sku=${sku}&${visit}`, {
|
|
242
|
+
headers: { authorization: `Bearer ${writeKey}`, ...hp.servingHeaders() },
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The SDK keeps the visit from the events it sends (Product Viewed, Clicked, Added, Removed, Added to
|
|
247
|
+
Wishlist, Products Searched, and the products of a list viewed or filtered), in this browser's storage for the
|
|
248
|
+
session, only while the visitor allows personalisation; withdrawing it forgets the visit. The call
|
|
249
|
+
is synchronous and never throws. Before the client has started on a page it answers with the visit
|
|
250
|
+
an earlier page kept (for 30 minutes from its last event), so a rail requested at page load, before
|
|
251
|
+
the SDK has downloaded, still carries it. It returns `{}` without personalisation consent, and for a
|
|
252
|
+
client whose host decided consent itself (no consent adapter), which the API would not personalise. `createAnalytics` clients have it as
|
|
253
|
+
`client.recommendationContext()`; `recommendationContextFrom(signals)` from `./session` builds the
|
|
254
|
+
same from session signals you keep yourself.
|
|
255
|
+
|
|
256
|
+
### Holdout and exposure
|
|
257
|
+
|
|
258
|
+
Since 3.2 (spec 1.3). A property can hold a share of its shoppers out of personalisation, so its
|
|
259
|
+
uplift can be measured. The settings carry each property's holdout, and the edge, the public API
|
|
260
|
+
and the SDK all hold out the same shoppers (by a hash of the anonymous id;
|
|
261
|
+
`spec/assignment/holdout.json` has the vectors). A held-out shopper gets the default everywhere.
|
|
262
|
+
Since spec 1.6 a property may also set a ramp, the share of all shoppers personalised; shoppers
|
|
263
|
+
above holdout + ramp are `excluded` and get the default too, with no exposure. Personalise in the page
|
|
264
|
+
only for the `personalised` group:
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
if (hp.personalisationGroup() === 'personalised') applySessionAudiences();
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`inHoldout()` means "show the default": true for held-out and excluded shoppers alike, and before
|
|
271
|
+
the client has loaded, so the older `if (!hp.inHoldout())` gate stays safe under a ramp.
|
|
272
|
+
`personalisationGroup()` gives the exact group.
|
|
273
|
+
|
|
274
|
+
A membership, decisions or recommendations response that personalised the page, or would have for
|
|
275
|
+
a held-out shopper, carries an `exposure` block. Record it after rendering, so both sides of the
|
|
276
|
+
holdout are counted on the same trigger. The SDK sends one Personalisation Exposed per token:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
const body = await res.json();
|
|
280
|
+
render(body);
|
|
281
|
+
hp.recordExposure(body);
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Since 3.7, recording a decisions response also puts its decisions in force. Every later event in
|
|
285
|
+
the browser session carries them as `context.decisions`, latest per slot, so a basket is credited
|
|
286
|
+
to the slot shown before it. Send the session id your events carry as `session_id` on
|
|
287
|
+
`/v1/decisions` too: the reward joins a basket to a decision within one session. Pass
|
|
288
|
+
`getDecisions` to supply `context.decisions` yourself instead.
|
|
289
|
+
|
|
290
|
+
### Server-side assignments
|
|
291
|
+
|
|
292
|
+
A page your server rendered from `POST /v1/assignments` and cached per arm has no `decision_id` in
|
|
293
|
+
it. Since 3.10, `hp.assignmentDecisionId({ slot, arm })` derives the one HP logged, synchronously,
|
|
294
|
+
for that page's `Slot Engaged`:
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
const decisionId = hp.assignmentDecisionId({ slot, arm });
|
|
298
|
+
if (decisionId) hp.slotEngaged({ slot, arm, engagement: 'view', decision_id: decisionId });
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
It reads the session cookie named in `identityCookies.sessionId`, else the visitor cookie
|
|
302
|
+
(`identityCookies.anonymousId`), as your renderer forwards them to HP, so it answers once
|
|
303
|
+
`bootstrap` has been called, before the client has started. It returns `null` before that, or
|
|
304
|
+
with neither cookie. The id is `UUIDv5(ASSIGNMENT_DECISION_NAMESPACE, "<session>:<slot>:<arm>")`;
|
|
305
|
+
`assignmentDecisionId({ sessionId, slot, arm })` from the main entry computes it from values you
|
|
306
|
+
pass, and the spec's `assignment/decision-id.json` has the vectors.
|
|
307
|
+
|
|
308
|
+
Such a page also reports the property holdout's exposure itself, when the slot is on screen, for
|
|
309
|
+
both groups. The answer's `hash` is `holdout` for a held-out visitor (cache that page apart from
|
|
310
|
+
the `""` one); on any other page with a hash the visitor was treated. The exposure token is the
|
|
311
|
+
assignment's derived id for the arm shown, control included. Since 3.13 the shares come from the
|
|
312
|
+
property's settings when the exposure has none:
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
const token = hp.assignmentDecisionId({ slot, arm });
|
|
316
|
+
hp.recordExposure({
|
|
317
|
+
exposure: { token, surface: 'decisions', holdout },
|
|
318
|
+
decisions: [holdout ? { slot, holdout: true } : { slot, arm, decision_id: token }],
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
A surface your server fetched from HP for the page, so the page never sees HP's answer, reports
|
|
323
|
+
its exposure with `surfaceViewed` when it comes into view: a product rail your server asked HP for,
|
|
324
|
+
which HP answers with its rail for a treated visitor and the site's own rail for a held-out one.
|
|
325
|
+
Since 3.14 the SDK groups the visitor itself, with the holdout settings, salt and visitor id serving
|
|
326
|
+
used (`spec/assignment/holdout.json`), so it is the group serving answered with. It records one
|
|
327
|
+
exposure per session per surface, and nothing for a visitor outside the experiment: no holdout in
|
|
328
|
+
the settings, outside the ramp, no visitor or session id, or a rail or audience without
|
|
329
|
+
personalisation consent, which serving needs to personalise them:
|
|
330
|
+
|
|
331
|
+
```js
|
|
332
|
+
hp.surfaceViewed({ surface: 'recommendations' });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Since 3.15 markup does the same without a call: `bootstrap`'s autocapture calls `surfaceViewed`
|
|
336
|
+
for an element tagged `data-hp-exposure="<surface>"` (`recommendations`, `decisions` or
|
|
337
|
+
`audiences`) once it is viewable, half on screen for a continuous second (MRC), including one
|
|
338
|
+
rendered after the SDK started. Tag what renders for both groups. `trackExposures(client)` from
|
|
339
|
+
`./auto` is the same for `createAnalytics` clients; `autocapture: { exposures: false }` turns it
|
|
340
|
+
off.
|
|
341
|
+
|
|
342
|
+
```html
|
|
343
|
+
<section data-hp-exposure="recommendations">…</section>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Bandit against the default
|
|
347
|
+
|
|
348
|
+
Since 3.16. A readout slot (the placement's **Peak readout** setting in the console) splits the
|
|
349
|
+
shoppers it personalises 50/50, by a hash of the device id salted with the slot
|
|
350
|
+
(`banditSplit(slot, anonymousId)`, vectors in the spec's `assignment/bandit-split.json`): half get
|
|
351
|
+
the bandit's arm, half the slot's default. The settings list each property's readout slots
|
|
352
|
+
(`holdouts.<property>.readoutSlots`), so `recordExposure` adds the shopper's side to the slot's
|
|
353
|
+
entry as `variant: 'bandit' | 'default'`, for a cached server-rendered page as for a serving
|
|
354
|
+
response (whose own `variant` wins). The default side is no decision: its `decision_id` is dropped
|
|
355
|
+
and it is not put in force. Nothing changes in your calls.
|
|
356
|
+
|
|
357
|
+
### Preview
|
|
358
|
+
|
|
359
|
+
Since 3.15. A trader's preview link from the console opens the site with `?hp_preview=<token>`.
|
|
360
|
+
The SDK keeps the token in the `thg_hp_preview` session cookie for the rest of the browser
|
|
361
|
+
session, for your server to forward to `/v1/assignments` as `preview` (the query parameter on the
|
|
362
|
+
first request, the cookie after), which then answers that link's arm and logs nothing. While a
|
|
363
|
+
preview is on, `recordExposure`, `surfaceViewed`, `data-hp-exposure` and `slotEngaged` send nothing
|
|
364
|
+
and put nothing in force: the trader is not a shopper, so must neither train a slot nor count in its
|
|
365
|
+
readout. Every other event is sent as usual. `?hp_preview=` with no value ends the preview. The
|
|
366
|
+
SDK never checks the token: serving does, and ignores one that has expired.
|
|
367
|
+
|
|
368
|
+
### Visit traits
|
|
369
|
+
|
|
370
|
+
A session audience's rules name fields the SDK keeps for the visit, from the events it sends:
|
|
371
|
+
`productViews`, `listViews`, `cartAdds`, `searches`, `browseViews.<path>`, `browseShare.<path>`,
|
|
372
|
+
`categoryViews.<path>`, `categoryShare.<path>` and, since 3.18, `basketValue`. A field this build
|
|
373
|
+
does not know fails its rule closed.
|
|
374
|
+
|
|
375
|
+
`basketValue` is the `value` of the last `cartUpdated` (Cart Updated), in that event's currency:
|
|
376
|
+
the basket as your platform priced it. It is never summed from `productAdded` lines, whose prices
|
|
377
|
+
can include tax where `value` does not. It is `null`, so every rule on it fails, until the visit
|
|
378
|
+
sends a Cart Updated, and again after one without a `value`. Send `cartUpdated` after every confirmed basket change, as the spec asks, or
|
|
379
|
+
a basket audience goes stale.
|
|
380
|
+
|
|
381
|
+
### Audiences only some integrations may target
|
|
382
|
+
|
|
383
|
+
Since 3.8, only audiences marked Targetable in the console are membership. Pass the membership
|
|
384
|
+
response's `audiences` and `qualified_for`, and the datafile's `session_audiences`, to
|
|
385
|
+
`resolveAudienceMemberships` exactly as before:
|
|
386
|
+
|
|
387
|
+
```js
|
|
388
|
+
const memberships = resolveAudienceMemberships(body.audiences, datafile.session_audiences, undefined, body.qualified_for);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
A targetable visit audience may require an audience nobody may target. The server sends that base
|
|
392
|
+
in `qualified_for` (never in `audiences`), and the datafile ships it with `targetable: false` if the
|
|
393
|
+
browser evaluates it. From 3.8 the resolver uses both to meet the `requires`, and never returns
|
|
394
|
+
them, so `context.audiences` and exposures carry targetable audiences only. Before 3.8 such an
|
|
395
|
+
audience matches nobody, and a shipped untargetable visit audience the visitor meets may be reported
|
|
396
|
+
as membership. Every other audience resolves as it did.
|
|
397
|
+
|
|
398
|
+
Social proof has a client of its own, which batches and caches, and never throws once created
|
|
399
|
+
(creating it without a `writeKey` or with an invalid `apiHost` throws):
|
|
400
|
+
|
|
401
|
+
```js
|
|
402
|
+
const proof = createSocialProof({ writeKey, getConsent: () => hp.consent });
|
|
403
|
+
const badges = await proof.forProducts(['123', '456']); // GET /v1/social-proof, Bearer writeKey
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
It sends the key and `hp-consent` itself; `propertyId` is needed only for a key scoped to several
|
|
407
|
+
properties.
|
|
408
|
+
|
|
409
|
+
## Consent
|
|
410
|
+
|
|
411
|
+
With a `consent` adapter the SDK is strict:
|
|
412
|
+
|
|
413
|
+
- **Before the CMP answers**, events are held in memory: not sent, not stored. They wait up to
|
|
414
|
+
`consentTimeoutMs`.
|
|
415
|
+
- **Analytics granted:** held events are sent, each carrying the answer in `context.consent`.
|
|
416
|
+
- **Analytics refused:** held events are dropped, and so is everything after. Nothing goes into
|
|
417
|
+
local or session storage.
|
|
418
|
+
- **No answer by the timeout:** held and later events are dropped until an answer arrives. Strict
|
|
419
|
+
is the only mode: an unanswered CMP is never a grant, however long it takes.
|
|
420
|
+
- **Page closed before an answer:** nothing is sent. Switching to another tab is not closing the
|
|
421
|
+
page: held events wait for the visitor to come back.
|
|
422
|
+
- **A navigation before an answer** (a sign-in that redirects) loses what the page held, unless
|
|
423
|
+
you opt in with `holdAcrossPages: true` (since 3.5). Then held events are also kept in
|
|
424
|
+
sessionStorage under `thg_hp_held:<property>` and sent when the next page's answer is a grant,
|
|
425
|
+
stamped with it. A refusal deletes them, and so does a tab left unanswered for 30 minutes. This
|
|
426
|
+
writes event data to the device before the visitor answers, which strict mode otherwise never
|
|
427
|
+
does, so turn it on only where your consent policy allows that.
|
|
428
|
+
- **The retry buffer:** a batch is kept in localStorage only while analytics is granted, and one
|
|
429
|
+
kept on an earlier page is replayed only once this page's answer is a grant. A refusal clears it.
|
|
430
|
+
- **Consent changes:** each change is recorded as a `Consent Updated` event. A decision that
|
|
431
|
+
matches the last one this device reported (read from `thg_hp_consent`) is not a change. A
|
|
432
|
+
withdrawal of analytics is also sent, once, stamped with the withdrawn state: the raw event
|
|
433
|
+
table is where a refusal is shown to have been received, and the warehouse's consent filter
|
|
434
|
+
keeps that event out of every model. After that, nothing more is sent.
|
|
435
|
+
- **Changes nothing announces:** after the first answer, an adapter whose `subscribe` cannot see
|
|
436
|
+
every change (a cookie adapter on a browser without the Cookie Store API, or your own adapter
|
|
437
|
+
without `observesChanges: true`) is re-read every 2 seconds while the page is visible.
|
|
438
|
+
|
|
439
|
+
- **Purposes the property does not ask for:** with the settings' `consent.purposes`, only the
|
|
440
|
+
purposes listed as `true` are reported (analytics always is). The others read as unanswered in
|
|
441
|
+
`context.consent`, the cookie and `hp-consent`, so nothing claims a grant the property never
|
|
442
|
+
sought.
|
|
443
|
+
|
|
444
|
+
`bootstrap` requires a consent adapter. `createAnalytics` without one sends straight away with
|
|
445
|
+
`context.consent: { basis: 'host' }`: that is for a host that has settled the lawful basis itself,
|
|
446
|
+
and `staticConsent` says so more explicitly. Leaving the adapter out is that statement.
|
|
447
|
+
|
|
448
|
+
- **Who decided (3.7.0, spec 1.5):** every event's `context.consent.basis` says whose decision it
|
|
449
|
+
carries. `visitor` is the visitor's own answer, from any adapter but `staticConsent`. `host` is
|
|
450
|
+
the site's decision: no adapter, or `staticConsent`. Under `visitor` a null purpose is unanswered,
|
|
451
|
+
so the warehouse keeps a browser event only with `analytics: true`. Under `host` a null purpose is
|
|
452
|
+
no statement, so the event is kept unless `analytics` is `false`. An adapter that reports the
|
|
453
|
+
site's decision rather than asking the visitor can say so with `basis: 'host'`.
|
|
454
|
+
|
|
455
|
+
### Adapters
|
|
456
|
+
|
|
457
|
+
All adapters are imported from `@thg-agentic/hyper-personalisation-web/consent`.
|
|
458
|
+
|
|
459
|
+
| Adapter | Reads | Not answered while |
|
|
460
|
+
|---|---|---|
|
|
461
|
+
| `didomiConsent({ purposes })` | `Didomi.getCurrentUserStatus().purposes`, subscribed via `didomiEventListeners` / `consent.changed` | Didomi is not loaded, `shouldUserStatusBeCollected()` is true, or `isUserStatusPartial()` is true |
|
|
462
|
+
| `oneTrustConsent({ groups, impliedConsent })` | `OnetrustActiveGroups`, else the `OptanonConsent` cookie's `groups=`; subscribed via `OneTrustGroupsUpdated`, `OptanonWrapper` and `OneTrust.OnConsentChanged` | The banner was never closed (`OptanonAlertBoxClosed`) and never interacted with (`interactionCount`) |
|
|
463
|
+
| `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 |
|
|
464
|
+
| `staticConsent(state)` | The state you pass, for sites with no CMP | Never |
|
|
465
|
+
|
|
466
|
+
- **Didomi purposes.** The defaults are:
|
|
467
|
+
- analytics: `analytics`, `measure_content_performance`, `audience_measurement`
|
|
468
|
+
- marketing: `marketing`, `targeting`, `advertising`, `select_personalized_ads`,
|
|
469
|
+
`create_ads_profile`
|
|
470
|
+
- personalisation: `personalisation`, `personalization`, `select_personalized_content`,
|
|
471
|
+
`create_content_profile`
|
|
472
|
+
|
|
473
|
+
An id also matches Didomi's suffixed custom ids (`analytics-BNjaanaW`). A spec purpose is
|
|
474
|
+
granted if any of its mapped Didomi purposes is enabled. If the notice defines none of them,
|
|
475
|
+
it is `null`.
|
|
476
|
+
- **OneTrust groups.** Defaults: analytics `C0002`, personalisation `C0003`, marketing `C0004`.
|
|
477
|
+
- `impliedConsent: true` accepts OneTrust's groups as soon as it loads. Only use it where the
|
|
478
|
+
geolocation rule is notice-only or opt-out, never for an opt-in (GDPR) audience.
|
|
479
|
+
- Global Privacy Control (`navigator.globalPrivacyControl`, or `isGpcEnabled=1` in the cookie)
|
|
480
|
+
turns marketing and both ad signals off. It does not change analytics.
|
|
481
|
+
- **Ad signals.** In every adapter, `ad_user_data` and `ad_personalization` follow marketing
|
|
482
|
+
unless you map them separately.
|
|
483
|
+
- **Your own banner (`cookieConsent`).** `name` and `categories.analytics` are required.
|
|
484
|
+
- By default the (URL-decoded) cookie value is a comma-separated list of accepted category ids;
|
|
485
|
+
an empty value is unknown, never a refusal.
|
|
486
|
+
- `refusedValue` is a literal value meaning "rejected everything".
|
|
487
|
+
- `parse(raw)` handles any other format: return the accepted ids as a `Set`, or null when
|
|
488
|
+
unreadable.
|
|
489
|
+
- After writing its cookie, the banner should call
|
|
490
|
+
`window.dispatchEvent(new Event('thg_hp_consent_change'))`.
|
|
491
|
+
|
|
492
|
+
Any object with `{ name, read(), subscribe(onChange) }` is an adapter. `read()` returns null until
|
|
493
|
+
the CMP has answered, and the five purposes otherwise.
|
|
494
|
+
|
|
495
|
+
**`forwardedConsent()`** (bootstrap provider `"forwarded"`) is for pages that run without the CMP:
|
|
496
|
+
a separate checkout app, a subdomain or a microsite on the same registrable domain as a page that
|
|
497
|
+
has it. The SDK on the CMP page records the decision in `thg_hp_consent`; set `consentCookieDomain`
|
|
498
|
+
there so the cookie reaches the other pages, and use `"forwarded"` on them. A site that renames the
|
|
499
|
+
cookie with `storageNames.consent` gets the same name read back by `"forwarded"` when both pages
|
|
500
|
+
pass the same `storageNames`, or passes `forwardedConsent({ name })` directly. No cookie, or no analytics answer in it,
|
|
501
|
+
means not answered, and strict mode then drops the events.
|
|
502
|
+
|
|
503
|
+
A checkout on **another registrable domain** never receives that cookie. Carry it in the checkout
|
|
504
|
+
handoff your servers already make:
|
|
505
|
+
|
|
506
|
+
1. The storefront's server reads `thg_hp_consent` from the shopper's request and puts it on the
|
|
507
|
+
checkout session it creates.
|
|
508
|
+
2. On every page it renders, checkout's server sets `thg_hp_consent_handoff` on its own host
|
|
509
|
+
from the session. The value is `formatConsentCookie(parseConsentCookie(value))` (from
|
|
510
|
+
`@thg-agentic/hyper-personalisation-web/consent`), so an unreadable value is unknown, never a
|
|
511
|
+
grant. The cookie is not `HttpOnly`. With no value it deletes the cookie.
|
|
512
|
+
3. The checkout page boots with `storageNames: { consent: 'thg_hp_consent_handoff' }` and
|
|
513
|
+
`"forwarded"`. Use a name of its own: a checkout host that also serves a storefront on its
|
|
514
|
+
own domain sees that storefront's `thg_hp_consent` first, and a delete cannot remove it.
|
|
515
|
+
|
|
516
|
+
Hand the device cookie over the same way if checkout's events should land on the storefront's
|
|
517
|
+
visitor. Never put the value on the checkout URL: it would end up in access logs and referrers.
|
|
518
|
+
|
|
519
|
+
## GA4 dataLayer
|
|
520
|
+
|
|
521
|
+
```js
|
|
522
|
+
const disconnect = connectDataLayer(analytics, {
|
|
523
|
+
dataLayer: window.dataLayer, // default; created if absent
|
|
524
|
+
ignore: ['view_promotion'], // e.g. when trackPromotions() measures impressions instead
|
|
525
|
+
steps: { shipping: 2, payment: 3 },
|
|
526
|
+
map: {
|
|
527
|
+
size_guide_opened: (entry, { products }) => ({ event: 'Product Clicked', properties: { products: products.slice(0, 1) } }),
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
What it reads and how it hooks in:
|
|
533
|
+
|
|
534
|
+
- **Existing entries.** Entries already on the array are read when you connect.
|
|
535
|
+
- **New pushes.** It wraps `push` and calls the previous `push` first. GTM does the same, so
|
|
536
|
+
both keep working whichever loads first. It never replaces the array and never throws into the
|
|
537
|
+
page.
|
|
538
|
+
- **Push shapes.** The GA4 standard only:
|
|
539
|
+
- `{ event: 'view_item', ecommerce: { items, currency, value } }`, as GTM documents it;
|
|
540
|
+
- non-ecommerce parameters at the top level (`{ event: 'login', method }`);
|
|
541
|
+
- `gtag('event', name, params)`.
|
|
542
|
+
- **Currency** is `ecommerce.currency`, set as `context.currency`.
|
|
543
|
+
- **Items.** GA4 items map to spec products:
|
|
544
|
+
- `productId(item)` (default `item_group_id ?? item_id`) becomes `product_id`, and `item_id`
|
|
545
|
+
becomes `sku`.
|
|
546
|
+
- `item_category` to `item_category5` are joined with `/`.
|
|
547
|
+
- `item_variant`, `discount`, `index` (as a 1-based position), `coupon` and `image_link` are
|
|
548
|
+
carried over.
|
|
549
|
+
- **Duplicates.** Identical pushes (same event, same item ids, same value) within 1 second
|
|
550
|
+
(`dedupeMs`) are sent once.
|
|
551
|
+
- **Unusable pushes.** A push that cannot make a valid event, such as one with no product ids
|
|
552
|
+
where products are required, is skipped.
|
|
553
|
+
- **Custom mappings.** `map` adds or overrides a mapping, keyed by GA4 event name. It receives the
|
|
554
|
+
push and helpers (`products`, `items`, `currency`, `value`, `promotions`, `toProducts`).
|
|
555
|
+
- **Any client.** It works with this package's client and with an analytics-next instance: it
|
|
556
|
+
only calls `track(event, properties, { context })`, plus `signedOut()` when the client has one.
|
|
557
|
+
|
|
558
|
+
| GA4 event | Spec event | Notes |
|
|
559
|
+
|---|---|---|
|
|
560
|
+
| `view_item` | Product Viewed | First item only (the event holds exactly one product); `value`, `currency` |
|
|
561
|
+
| `view_item_list` | Product List Viewed | `list_id` from `item_list_id`, else `item_list_name` |
|
|
562
|
+
| `view_search_results` | Products Searched, or Product List Viewed | Products Searched if `search_term` is present |
|
|
563
|
+
| `search` | Products Searched | `query` = `search_term` |
|
|
564
|
+
| `select_item` | Product Clicked | |
|
|
565
|
+
| `add_to_cart` / `remove_from_cart` | Product Added / Product Removed | `cart_id` when present |
|
|
566
|
+
| `view_cart` | Cart Viewed | `value`, `currency`, `coupon`, products |
|
|
567
|
+
| `begin_checkout` | Checkout Started | `value`, `currency`, `coupon`, products |
|
|
568
|
+
| `add_shipping_info` | Checkout Step Completed | `step` 2, `step_name` `'shipping'`, `shipping_method` = `shipping_tier` |
|
|
569
|
+
| `add_payment_info` | Payment Info Entered | `step` 3, `step_name` `'payment'`, `payment_method` = `payment_type` |
|
|
570
|
+
| `view_promotion` / `select_promotion` | Promotion Viewed / Promotion Clicked | Fields from `ecommerce` or its items; one event per `promotion_id`. `creative_slot` is sent as Segment's `position` |
|
|
571
|
+
| `add_to_wishlist` | Product Added to Wishlist | |
|
|
572
|
+
| `share` | Product Shared | Only when `item_id` is present and `content_type` is absent or a product (`product`, `item`); `share_via` = `method`, `product_id` = `item_id` |
|
|
573
|
+
| `login` / `sign_up` | Signed In / Signed Up | `method` mapped to the spec enum (`email` becomes `password`, anything unknown becomes `other`) |
|
|
574
|
+
| `purchase`, `refund` | not mapped | Orders are reported by the service that took them, which sees every order exactly once, including app and redirect orders. The ingest refuses them from browser keys. |
|
|
575
|
+
|
|
576
|
+
GA4 has no event for removing from a wishlist, moving a wishlist item to the basket, sharing a
|
|
577
|
+
basket, signing out or filtering a list, and no coupon events at all (`coupon` is a parameter on
|
|
578
|
+
its commerce events, carried through above). Add them with `map` or a preset if your site pushes
|
|
579
|
+
them, or call the client's named methods directly.
|
|
580
|
+
|
|
581
|
+
## Presets
|
|
582
|
+
|
|
583
|
+
The core knows GA4 and Segment. A preset teaches it one platform's conventions. It is plain
|
|
584
|
+
configuration, spread into the options, and each part can be overridden:
|
|
585
|
+
|
|
586
|
+
- `eventName(entry)`: the GA4 name of a push (default `entry.event`);
|
|
587
|
+
- `transform(entry)`: rewrites a push into the GA4 shape before anything reads it, or returns null
|
|
588
|
+
to skip it;
|
|
589
|
+
- `productId(item)`: the parent product id of an item;
|
|
590
|
+
- `map` and `ignore`: as above;
|
|
591
|
+
- `consent`: optional adapter factories by provider name, used by `bootstrap`.
|
|
592
|
+
|
|
593
|
+
`firstId(...candidates)`, exported from `./datalayer`, is the id helper the core uses: the first
|
|
594
|
+
non-blank candidate as a string, or null.
|
|
595
|
+
|
|
596
|
+
A preset for a hypothetical platform whose pushes look like
|
|
597
|
+
`{ acmeEvent: 'basket_add', money: 'EUR', lines: [{ sku, style }] }`:
|
|
598
|
+
|
|
599
|
+
```js
|
|
600
|
+
export const acme = {
|
|
601
|
+
eventName: (entry) => ({ basket_add: 'add_to_cart', pdp_view: 'view_item' })[entry.acmeEvent] ?? null,
|
|
602
|
+
transform: (entry) =>
|
|
603
|
+
entry.acmeEvent
|
|
604
|
+
? {
|
|
605
|
+
...entry,
|
|
606
|
+
ecommerce: {
|
|
607
|
+
currency: entry.money,
|
|
608
|
+
items: (entry.lines ?? []).map((line) => ({ item_id: line.sku, item_group_id: line.style, ...line })),
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
: entry,
|
|
612
|
+
productId: (item) => item.item_group_id ?? item.item_id,
|
|
613
|
+
map: { wishlist_remove: (entry, { products }) => ({ event: 'Product Removed from Wishlist', properties: { products } }) },
|
|
614
|
+
ignore: [],
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
connectDataLayer(analytics, { ...acme, ignore: [...acme.ignore, 'begin_checkout'] });
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
## Identity and hashing
|
|
621
|
+
|
|
622
|
+
`emailSha256(email)` returns the SHA-256 of the trimmed, lowercased email as lowercase hex,
|
|
623
|
+
synchronously (since 3.9), so a page hashes an address in the same call that reports it and never
|
|
624
|
+
awaits the SDK. This is the rule `identify.email_sha256` expects. It returns null for anything that
|
|
625
|
+
is not an email address. `hashEmail(email)` is the same as a promise, and `sha256(value)` the
|
|
626
|
+
underlying helper.
|
|
627
|
+
|
|
628
|
+
`hashPhone(phone)` returns the SHA-256 of the E.164 number (`+447700900123`), after removing spaces,
|
|
629
|
+
dots, dashes and brackets. This is the rule `identify.phone_sha256` expects. It returns null for a
|
|
630
|
+
national number such as `07700 900123`: convert to E.164 first, since the country cannot be guessed
|
|
631
|
+
and a number hashed as typed never matches the same number sent in E.164.
|
|
632
|
+
|
|
633
|
+
Already have the digest? Platforms encode SHA-256 as hex or as base64 (some platforms return
|
|
634
|
+
base64). `bootstrap`'s `identity.emailSha256` and `identify`'s `email_sha256` (since 3.9) accept
|
|
635
|
+
either, and `normaliseSha256(value)` converts either to the lowercase hex the spec wants. It re-encodes the same digest and never
|
|
636
|
+
re-hashes, so pass the hash, never the email.
|
|
637
|
+
|
|
638
|
+
## Handing off to server producers
|
|
639
|
+
|
|
640
|
+
Server-side producers (for example an order service using the Java SDK) read two first-party cookies:
|
|
641
|
+
|
|
642
|
+
- **`thg_hp_consent`** carries the visitor's consent decision.
|
|
643
|
+
- It is written whenever the decision is known or changes, with `Path=/`, `SameSite=Lax`, a
|
|
644
|
+
`Max-Age` of 180 days, `Secure` on https, and `Domain` from `consentCookieDomain`.
|
|
645
|
+
- Its value is `a=1&m=0&p=1&aud=0&adp=0`: analytics, marketing, personalisation,
|
|
646
|
+
ad_user_data and ad_personalization.
|
|
647
|
+
- `1` means granted, `0` refused, and a missing key means unknown.
|
|
648
|
+
- The format is byte-identical to `ConsentCookie` in analytics-java, and a test checks this
|
|
649
|
+
against the Java parser.
|
|
650
|
+
- **The device and session cookies.** The device cookie is the one the source's settings choose:
|
|
651
|
+
your own, or `thg_hp_anonymous_id` under `hp_cookie`. Your server must read that one, and the
|
|
652
|
+
session cookie you name in `identityCookies` (or return from `getSessionId`), so both sides agree
|
|
653
|
+
on who the visitor is. `client.anonymousId` is the id events carry.
|
|
654
|
+
|
|
655
|
+
## Choosing a delivery path
|
|
656
|
+
|
|
657
|
+
There are two ways to deliver events. Both use the same consent adapters, the same consent
|
|
658
|
+
logic, the same context shaping and URL scrubbing, the same settings and the same cookie. Only the
|
|
659
|
+
delivery differs.
|
|
660
|
+
|
|
661
|
+
**Built-in client** (`bootstrap` / `createAnalytics`)
|
|
662
|
+
- Dependency-free.
|
|
663
|
+
- Batched, with a retry buffer in localStorage: each batch is stored before it is sent and
|
|
664
|
+
removed when the ingest accepts it (or rejects it for good), so an outage costs a delay, not the
|
|
665
|
+
events. The buffer is per tenant and capped at 20 batches and 1 MB, oldest dropped first.
|
|
666
|
+
- On page unload, sends by `sendBeacon` in chunks that fit.
|
|
667
|
+
- Never throws into the page. An event JSON cannot serialise (a BigInt, a circular object, a
|
|
668
|
+
throwing `toJSON`) is dropped on its own, logged under `debug`, and the queue behind it carries
|
|
669
|
+
on. A `Date` in properties is sent as its ISO string.
|
|
670
|
+
- Size: the CDN script is `bootstrap` with every consent adapter and the dataLayer bridge. A test
|
|
671
|
+
fails it past 90 KB gzip, and the inline snippet past 1 KB; the budget catches a runaway bundle.
|
|
672
|
+
|
|
673
|
+
**analytics-next plugins** (`cdpPlugins` from `./segment`)
|
|
674
|
+
- For sites already using `@segment/analytics-next`: keep it, and add the spec on top of it.
|
|
675
|
+
- Size: about 50 KB gzip as a single bundle; with code splitting, about 39 KB loads up front.
|
|
676
|
+
|
|
677
|
+
Use the built-in client on performance-critical storefronts. Use the plugins if you are already
|
|
678
|
+
on Segment: the wire format and the spec are identical.
|
|
679
|
+
|
|
680
|
+
```js
|
|
681
|
+
import { AnalyticsBrowser } from '@segment/analytics-next';
|
|
682
|
+
import {
|
|
683
|
+
cdpPlugins,
|
|
684
|
+
DEFAULT_API_HOST,
|
|
685
|
+
hpLoadOptions,
|
|
686
|
+
} from '@thg-agentic/hyper-personalisation-web/segment';
|
|
687
|
+
import { didomiConsent } from '@thg-agentic/hyper-personalisation-web/consent';
|
|
688
|
+
|
|
689
|
+
const hp = { writeKey: 'hp_pk_live_…' };
|
|
690
|
+
const analytics = AnalyticsBrowser.load(
|
|
691
|
+
{
|
|
692
|
+
writeKey: hp.writeKey,
|
|
693
|
+
cdnURL: DEFAULT_API_HOST, // or your proxy, passed as hp.apiHost too
|
|
694
|
+
plugins: cdpPlugins({ consent: didomiConsent(), getCurrency: () => getCookie('currency') }),
|
|
695
|
+
},
|
|
696
|
+
hpLoadOptions(hp),
|
|
697
|
+
);
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Things to get right when configuring analytics-next:
|
|
701
|
+
|
|
702
|
+
- **`cdnURL` is the HP API** (`DEFAULT_API_HOST`), or your proxy. analytics-next fetches its settings from
|
|
703
|
+
`{cdnURL}/v1/projects/{writeKey}/settings`, the same Segment-compatible document `bootstrap`
|
|
704
|
+
reads. The plugins take the property, environment, reported purposes, allowlist and tracking plan
|
|
705
|
+
from its `hp` block.
|
|
706
|
+
- **`hpLoadOptions` is required with it.** The settings carry no `Segment.io` destination, and
|
|
707
|
+
without one analytics-next sends to api.segment.io with no key. `hpLoadOptions` points delivery
|
|
708
|
+
at the settings' apiHost with the write key, turns analytics-next's own telemetry off (it would
|
|
709
|
+
otherwise POST to `<apiHost>/m` every 30 seconds), and makes a disabled key a no-op.
|
|
710
|
+
- **`plugins` go in the first argument** of `AnalyticsBrowser.load`. In the second, they are
|
|
711
|
+
ignored.
|
|
712
|
+
- **Endpoints.** analytics-next POSTs one event per request to `<apiHost>/v1/t`, `/v1/p` and
|
|
713
|
+
`/v1/i`, or a `{ batch }` body to `/v1/b` with the batching strategy. The write key is in the
|
|
714
|
+
body and in an `Authorization: Basic` header.
|
|
715
|
+
- **Keep strict consent strict.** `hpLoadOptions` sets `disableClientPersistence`, which stops
|
|
716
|
+
analytics-next writing its queue and ids to storage before consent. Keep it if you build the
|
|
717
|
+
options yourself.
|
|
718
|
+
- **Device id.** Pass `getAnonymousId` to `cdpPlugins` to use the site's device id instead of
|
|
719
|
+
analytics-next's.
|
|
720
|
+
|
|
721
|
+
`@segment/analytics-consent-tools` is not used, for two reasons. It writes
|
|
722
|
+
`context.consent.categoryPreferences`, which is not the spec's consent block. And it keeps sending
|
|
723
|
+
to Segment.io after a withdrawal.
|
|
724
|
+
|
|
725
|
+
## CDN script
|
|
726
|
+
|
|
727
|
+
For a site without a bundler, `dist/hp.min.js` is `bootstrap` as one script, and
|
|
728
|
+
`dist/snippet.min.js` is the loader to paste inline in `<head>`, Segment-style: it puts a stub on
|
|
729
|
+
`window.hp` that queues every call, loads the script asynchronously, and the script drains the
|
|
730
|
+
queue into the client once the settings have answered.
|
|
731
|
+
|
|
732
|
+
```html
|
|
733
|
+
<script>
|
|
734
|
+
/* dist/snippet.min.js, with your write key and options in its hp.load(…) call */
|
|
735
|
+
</script>
|
|
736
|
+
<script>
|
|
737
|
+
hp.productViewed({ products: [{ product_id: '10530943' }] }); // queued until the script loads
|
|
738
|
+
</script>
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
The snippet's `hp.load(config, options, src)` takes the same `{ writeKey }` (and optional `apiHost`) and options as
|
|
742
|
+
`bootstrap`, and the script URL: `https://cdn.jsdelivr.net/npm/@thg-agentic/hyper-personalisation-web@3/dist/hp.min.js`
|
|
743
|
+
once published, or wherever you host `dist/hp.min.js`. Once loaded, `window.hp` stays the same
|
|
744
|
+
object, forwards every call to the client, and holds it as `hp.client`. A key the settings do not
|
|
745
|
+
enable leaves every method a no-op. Without the snippet, call `hp.load(config, options)` after the
|
|
746
|
+
script has loaded.
|
|
747
|
+
|
|
748
|
+
The ESM build is the primary distribution; the script is for pages that cannot use it. It targets
|
|
749
|
+
evergreen browsers.
|
|
750
|
+
|
|
751
|
+
## Storage
|
|
752
|
+
|
|
753
|
+
Everything the SDK stores carries the `thg_hp_` prefix, and every name can be changed with
|
|
754
|
+
`storageNames` (on `bootstrap`, `createAnalytics` or `cdpPlugins`). The names are page-wide: one
|
|
755
|
+
SDK per page.
|
|
756
|
+
|
|
757
|
+
| Kind | Default name | Where | Scope |
|
|
758
|
+
|---|---|---|---|
|
|
759
|
+
| `consent` | `thg_hp_consent` | cookie | the consent decision, for your servers and `forwarded` pages |
|
|
760
|
+
| `retry` | `thg_hp_retry:<property>` | localStorage | batches waiting for the ingest; only under a grant |
|
|
761
|
+
| `sessionSignals` | `thg_hp_session_signals:<property>:<session>` | localStorage (sessionStorage until a session is bound) | in-session audience counters |
|
|
762
|
+
| `identified` | `thg_hp_identified:<property>` | sessionStorage | the identify already sent this session |
|
|
763
|
+
| `previousPage` | `thg_hp_prev_page:<property>` | sessionStorage | the previous page view's messageId; only under a grant |
|
|
764
|
+
| `decisions` | `thg_hp_decisions:<property>` | sessionStorage | the decisions in force, latest per slot; only under a grant |
|
|
765
|
+
| `settings` | `thg_hp_settings:<writeKey>` | localStorage | the last good settings; only under a grant |
|
|
766
|
+
| `held` | `thg_hp_held:<property>` | sessionStorage | events held for an unanswered CMP; only with `holdAcrossPages` |
|
|
767
|
+
| `carried` | `thg_hp_carried:<writeKey>` | sessionStorage | `hp` calls made before the SDK started, on a page that navigated first (3.9); only under a grant, or with `holdAcrossPages` |
|
|
768
|
+
| `recommendationContext` | `thg_hp_recommendation_context` | localStorage | The visit's wire form, for `hp.recommendationContext()` on the next page before the SDK starts there; 30 minutes from the last event, only while personalisation is granted (3.11) |
|
|
769
|
+
| `anonymousId` | `thg_hp_anonymous_id` | cookie, one per browser | Hyper-Personalisation's device id, for a source set to `hp_cookie`; only under a grant (see [Device ids](#device-ids)) |
|
|
770
|
+
| `session` | `thg_hp_session` | cookie, one per browser | the session id the SDK keeps when you name none, 30 minutes from the last event; only under a grant (3.15, see [Session ids](#session-ids)) |
|
|
771
|
+
| `preview` | `thg_hp_preview` | session cookie | a trader's preview token from `?hp_preview=` (3.15, see [Preview](#preview)) |
|
|
772
|
+
| `debug` | `thg_hp_debug` | sessionStorage | a developer's debug token from `?hp_debug=` (3.18), checked against the settings on every page |
|
|
773
|
+
| — | `thg_hp_conformance_page_type` | sessionStorage | the previous page's type, only while the development-only conformance checker runs |
|
|
774
|
+
|
|
775
|
+
Keys are scoped by property because one origin can serve several tenants. The names the SDK used
|
|
776
|
+
before 3.0 (`cdp.retry`, `cdp_session_signals`, `cdp_identified`, `cdp_prev_page_message_id`) are
|
|
777
|
+
moved to the new names once, on the first page that loads 3.x, and deleted. A banner using
|
|
778
|
+
`cookieConsent` announces a change with the `thg_hp_consent_change` window event.
|
|
779
|
+
|
|
780
|
+
### Device ids
|
|
781
|
+
|
|
782
|
+
Each web source takes its device ids (`anonymousId`) from one place, chosen in the console and
|
|
783
|
+
served in the settings as `hp.anonymousId`. Since 3.18 an event never leaves without one: when the
|
|
784
|
+
site's id is missing, empty or a placeholder, the SDK's own cookie stands in, as analytics.js does.
|
|
785
|
+
|
|
786
|
+
| `hp.anonymousId` | The device id | |
|
|
787
|
+
|---|---|---|
|
|
788
|
+
| `{ source: 'site_cookie', cookie: 'site_visitor_id' }` | your own first-party cookie, by the name the console gives | read only; a visitor without it gets the SDK's `thg_hp_anonymous_id` (3.18; before, no `anonymousId`) |
|
|
789
|
+
| `{ source: 'site_cookie', cookie: null }` | the cookie you name in `identityCookies.anonymousId` (or return from `getAnonymousId`) | as above. Every web source that existed before the setting is set to this |
|
|
790
|
+
| `{ source: 'hp_cookie' }` | Hyper-Personalisation's first-party cookie, `thg_hp_anonymous_id` | minted by the SDK; your own device cookie is ignored. The default for a new source |
|
|
791
|
+
| absent (settings from before 3.6) | as `site_cookie` with `cookie: null` | |
|
|
792
|
+
|
|
793
|
+
The `hp_cookie` cookie is one per browser, not per property. It is written with `Path=/`,
|
|
794
|
+
`SameSite=Lax`, `Secure` on https, and the `Domain` of `consentCookieDomain` when you set one. Its
|
|
795
|
+
`Max-Age` is 400 days, and it is written again on every page under a grant, so it lasts 400 days
|
|
796
|
+
from the last visit. With a consent adapter it is never written before analytics is granted: until
|
|
797
|
+
then the id lives in memory, and in the held store with `holdAcrossPages`, so a redirect keeps it.
|
|
798
|
+
A refusal or withdrawal deletes it, and the id with it: a later grant, on the same page or another,
|
|
799
|
+
starts a new device, as `analytics.reset()` does in analytics.js (3.18). Without a consent adapter
|
|
800
|
+
you have decided the lawful basis yourself, so it is written at once.
|
|
801
|
+
|
|
802
|
+
Changing a source's choice gives every returning visitor a new device id. A device id 3.5 minted
|
|
803
|
+
into localStorage (`thg_hp_anonymous_id:<property>`) becomes the cookie's value under a grant, and
|
|
804
|
+
the localStorage key is removed. `mintAnonymousId` is deprecated since 3.6 and ignored; with
|
|
805
|
+
`debug` the SDK says so once. `client.anonymousId` reads the id events carry, so a server can be
|
|
806
|
+
handed the same one.
|
|
807
|
+
|
|
808
|
+
### Session ids
|
|
809
|
+
|
|
810
|
+
Every event carries `context.session_id`. Name your own session cookie in
|
|
811
|
+
`identityCookies.sessionId`, or pass `getSessionId`, and the SDK reads it. Without either (since
|
|
812
|
+
3.15), or while it is missing or empty (since 3.18), the SDK keeps its own, as Amplitude and GA4 do: a random UUID in the `thg_hp_session` cookie, ended by 30 minutes
|
|
813
|
+
without an event. Its `Max-Age` is 30 minutes and every event writes it again, so it is shared by
|
|
814
|
+
the site's tabs and ends on its own. It is written with the attributes of the device cookie. With a
|
|
815
|
+
consent adapter it is never written before analytics is granted (until then the session lives in
|
|
816
|
+
memory), and a refusal deletes it. Before 3.15 such a site sent `session_id: null`.
|
|
817
|
+
|
|
818
|
+
## CSP and proxying
|
|
819
|
+
|
|
820
|
+
The SDK makes requests only to `apiHost` (the settings, and the events unless the settings name
|
|
821
|
+
another host), so allow it in `connect-src`: `resolveApiHost(apiHost)` is that origin, the HP API
|
|
822
|
+
(`DEFAULT_API_HOST`) when you set none. Since 3.18 that is `https://hp.thgingenuity.com`
|
|
823
|
+
(`connect-src https://hp.thgingenuity.com`); 3.17 used HP's nonprod host and 3.16 and earlier a
|
|
824
|
+
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
|
|
825
|
+
would see the CDN's IP and country instead of the shopper's.
|
|
826
|
+
|
|
827
|
+
### Default host by release
|
|
828
|
+
|
|
829
|
+
| Release | `DEFAULT_API_HOST` | For |
|
|
830
|
+
|---|---|---|
|
|
831
|
+
| 3.12 to 3.16 | A staging host | Being retired. Upgrade. |
|
|
832
|
+
| 3.17 | `https://hp.uat.thgingenuity.com` | Interim: HP's nonprod stack. Test keys only. |
|
|
833
|
+
| 3.18 | `https://hp.thgingenuity.com` | Production, for test and live keys alike. Go live on this. |
|
|
834
|
+
|
|
835
|
+
The key, not the host, keeps test traffic out of live results, so UAT and preview builds use the
|
|
836
|
+
same host with a `hp_pk_test_…` key. Upgrade from 3.17 before shipping a live key.
|
|
837
|
+
|
|
838
|
+
The SDK loads no scripts and sets no cookie except `thg_hp_consent`, once the CMP has answered,
|
|
839
|
+
and `thg_hp_anonymous_id` for a source set to `hp_cookie`, once analytics is granted.
|
|
840
|
+
|
|
841
|
+
## Debugging
|
|
842
|
+
|
|
843
|
+
- **`debug: true`** logs every event as it is built, plus any delivery problems.
|
|
844
|
+
- **`validate: validateEvent`** checks every event against the spec as it is built and logs
|
|
845
|
+
anything the ingest would quarantine. Import `validateEvent` from `spec/lib/index.mjs`, or from
|
|
846
|
+
`@thg-agentic/hyper-personalisation-spec` where it is published, and use it in development only, since it pulls in
|
|
847
|
+
ajv. Add `throwOnInvalid: true` to fail fast in tests.
|
|
848
|
+
- **`createConformanceChecker`**, from `./conformance`, reports what a page is missing against
|
|
849
|
+
the registry's expectations (`page_expectations`). Since spec 1.2 a `list` page owes Product List
|
|
850
|
+
Viewed, and `checkout` owes Checkout Step Viewed on every page and Checkout Started on entry:
|
|
851
|
+
the first page of a run of checkout pages, known from the previous page's type in
|
|
852
|
+
`sessionStorage` (`thg_hp_conformance_page_type`), so a checkout on its own origin counts its first
|
|
853
|
+
page as the entry. It also looks inside `products[]`, against the product version each event
|
|
854
|
+
holds. An item the ingest would reject is an error. Two things the ingest accepts but the
|
|
855
|
+
warehouse cannot use are warnings: priced products with no currency, on the event or in
|
|
856
|
+
`context.currency` (the amounts drop out of revenue), and a category separated by `>` or `|`
|
|
857
|
+
rather than `/` (it counts as one level).
|
|
858
|
+
|
|
859
|
+
## Licence
|
|
860
|
+
|
|
861
|
+
MIT. See [LICENSE](LICENSE).
|