@thg-agentic/hyper-personalisation-web 3.18.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { bump, categoryKey } from './category.js';
1
+ import { bump, categoryKey, pathPrefix } from './category.js';
2
2
  import { readSignals, writeSignals } from './signals.js';
3
3
 
4
4
  const RECENT_LIMIT = 20;
@@ -13,11 +13,46 @@ const ENGAGEMENT_EVENTS = new Set([
13
13
  'Product Added to Wishlist',
14
14
  ]);
15
15
 
16
- /** Engagement the warehouse's category affinity does not count, so a session measure must not. */
17
- const UNCOUNTED_ENGAGEMENT = new Set([
18
- 'Product Clicked',
19
- 'Product Added to Wishlist',
20
- ]);
16
+ /**
17
+ * The browsing events the warehouse's category affinity counts (identifier_affinity_daily), so a
18
+ * session measure counts the same: a product shown in a listing, removed or found in search is not
19
+ * interest. The warehouse also counts orders, which a visit's own measure leaves out.
20
+ */
21
+ const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
22
+
23
+ /** The most listing categories a visit counts views of: more than any visit browses. */
24
+ const LIST_CATEGORY_LIMIT = 50;
25
+
26
+ /**
27
+ * One interaction with a browse key, and beside it the category of the listing it was reached from
28
+ * (null for none), so each trail entry is judged on its own listing, as gold judges each event.
29
+ * Returns the key used.
30
+ */
31
+ function browsed(s, value) {
32
+ const key = bump(s.recentBrowse, value);
33
+ if (!key) return null;
34
+ // Older visits have no categories: the two lists stay aligned at the head, where entries enter.
35
+ s.recentBrowseCategories = [
36
+ s.browsingListing?.category ?? null,
37
+ ...s.recentBrowseCategories,
38
+ ].slice(0, s.recentBrowse.length);
39
+ return key;
40
+ }
41
+
42
+ /** One listing view of a category, the counts kept to the categories most viewed. */
43
+ function countListView(s, category) {
44
+ const counts = s.listViewsByCategory;
45
+ counts[category] = (counts[category] ?? 0) + 1;
46
+ const categories = Object.keys(counts);
47
+ if (categories.length > LIST_CATEGORY_LIMIT) {
48
+ const kept = {};
49
+ for (const c of categories
50
+ .sort((a, b) => counts[b] - counts[a])
51
+ .slice(0, LIST_CATEGORY_LIMIT))
52
+ kept[c] = counts[c];
53
+ s.listViewsByCategory = kept;
54
+ }
55
+ }
21
56
 
22
57
  /**
23
58
  * Fold one CDP event into the session signals.
@@ -36,38 +71,64 @@ export function recordSessionEvent(eventName, properties) {
36
71
  switch (eventName) {
37
72
  case 'Product Viewed':
38
73
  s.productViews += 1;
39
- bump(s.recentBrowse, s.browsingCategory);
74
+ browsed(s, s.browsingCategory);
40
75
  break;
41
76
  case 'Product List Viewed':
42
- s.listViews += 1;
43
- // Only a category list is browsing. A rail or carousel names its widget (no slash, or a
44
- // `cdp-` id), and counting it would let personalisation measure itself.
77
+ // Only a category list is browsing, or a category page viewed. A rail or carousel names its
78
+ // widget (no slash, or a `cdp-` id), and counting it would let personalisation measure itself.
79
+ // A listing must name its own `category`, as the warehouse requires. Each trail entry keeps
80
+ // that listing's category, as gold judges each event on its own: whether the catalogue files
81
+ // products under it (a category page, not a merchandising list) is decided against the
82
+ // datafile's `browse_categories` when audiences are resolved.
45
83
  {
46
84
  const list = properties?.list_id;
85
+ const category = properties?.category;
47
86
  const isCategoryPath =
48
87
  typeof list === 'string' &&
49
88
  list.includes('/') &&
50
89
  !list.startsWith('cdp-');
51
- if (isCategoryPath) {
52
- s.browsingCategory = bump(s.recentBrowse, list) ?? s.browsingCategory;
90
+ if (!isCategoryPath) break;
91
+ const listed =
92
+ typeof category === 'string'
93
+ ? pathPrefix(categoryKey(category)) || null
94
+ : null;
95
+ const key = listed ? pathPrefix(categoryKey(list)) : '';
96
+ if (!key) {
97
+ // A page that is not recorded: what is viewed from it is not the last page's browsing.
98
+ s.browsingCategory = null;
99
+ s.browsingListing = null;
100
+ break;
53
101
  }
102
+ s.browsingListing = { key, category: listed };
103
+ browsed(s, key);
104
+ s.browsingCategory = key;
105
+ countListView(s, listed);
106
+ s.listViews += 1;
54
107
  }
55
108
  break;
56
109
  case 'Product List Filtered': {
57
- // Storefronts whose menus link to facet URLs navigate by filtering.
110
+ // Storefronts whose menus link to facet URLs navigate by filtering: a facet of the listing
111
+ // being browsed, which carries that listing's category. A facet of a listing that was not
112
+ // recorded is not browsing either.
58
113
  const change = properties?.change;
59
114
  const list =
60
115
  typeof properties?.list_id === 'string' ? properties.list_id : '';
61
116
  const facet = typeof change?.value === 'string' ? change.value : '';
62
- if (facet && change?.action !== 'removed') {
63
- s.browsingCategory =
64
- bump(s.recentBrowse, `${list}/${facet}`) ?? s.browsingCategory;
117
+ const listing = s.browsingListing;
118
+ if (
119
+ facet &&
120
+ change?.action !== 'removed' &&
121
+ listing &&
122
+ pathPrefix(categoryKey(list)) === listing.key
123
+ ) {
124
+ const key = browsed(s, `${list}/${facet}`);
125
+ if (key) s.browsingCategory = key;
65
126
  }
66
127
  break;
67
128
  }
68
129
  case 'Product Added': {
69
130
  s.cartAdds += 1;
70
- bump(s.recentBrowse, s.browsingCategory);
131
+ browsed(s, s.browsingCategory);
71
132
  for (const p of products) {
72
133
  const id = p.product_id;
73
134
  if (typeof id !== 'string' || !id) continue;
@@ -159,7 +220,7 @@ export function recordSessionEvent(eventName, properties) {
159
220
  ].slice(0, RECENT_LIMIT);
160
221
  }
161
222
  if (
162
- !UNCOUNTED_ENGAGEMENT.has(eventName) &&
223
+ CATEGORY_EVENTS.has(eventName) &&
163
224
  typeof p.category === 'string' &&
164
225
  p.category
165
226
  ) {
@@ -131,15 +131,38 @@ export function resolveAudiences(
131
131
  sessionAudiences,
132
132
  signals = readSignals(),
133
133
  qualifiedFor = [],
134
+ browseCategories = null,
134
135
  ) {
135
136
  return resolveAudienceMemberships(
136
137
  precomputed,
137
138
  sessionAudiences,
138
139
  signals,
139
140
  qualifiedFor,
141
+ browseCategories,
140
142
  ).map((m) => m.id);
141
143
  }
142
144
 
145
+ /**
146
+ * The visit as the warehouse counts browsing: only the listings whose own `category` the catalogue
147
+ * files products under (the datafile's `browse_categories`), so a merchandising list (best sellers,
148
+ * new in, a sale) is never browsing interest. Each trail entry is judged on the listing it came
149
+ * from. Without the list (a datafile that predates it, or a serving store with no catalogue yet),
150
+ * the visit as recorded.
151
+ */
152
+ export function catalogueBrowse(signals, browseCategories) {
153
+ if (!Array.isArray(browseCategories)) return signals;
154
+ const held = new Set(browseCategories);
155
+ const categories = signals.recentBrowseCategories ?? [];
156
+ const recentBrowse = (signals.recentBrowse ?? []).filter((_, i) =>
157
+ held.has(categories[i]),
158
+ );
159
+ const listViews = Object.entries(signals.listViewsByCategory ?? {}).reduce(
160
+ (sum, [category, n]) => (held.has(category) ? sum + n : sum),
161
+ 0,
162
+ );
163
+ return { ...signals, recentBrowse, listViews };
164
+ }
165
+
143
166
  /**
144
167
  * The same resolution, with where each membership came from, for `context.audiences`.
145
168
  *
@@ -151,7 +174,9 @@ export function resolveAudienceMemberships(
151
174
  sessionAudiences,
152
175
  signals = readSignals(),
153
176
  qualifiedFor = [],
177
+ browseCategories = null,
154
178
  ) {
179
+ const visit = catalogueBrowse(signals, browseCategories);
155
180
  const active = new Set(precomputed);
156
181
  const bySlug = new Map();
157
182
  // Qualifying on history is not membership of a mixed audience, so this stays out of `active`.
@@ -176,7 +201,7 @@ export function resolveAudienceMemberships(
176
201
  if (!required.every(met)) continue;
177
202
  if (audience.mixed && !qualified.has(audience.slug)) continue;
178
203
  if (!audience.rules || audience.rules.length === 0) continue;
179
- if (sessionRulesHold(audience, signals)) {
204
+ if (sessionRulesHold(audience, visit)) {
180
205
  active.add(audience.slug);
181
206
  // Mixed is its own source: half of it was decided by a batch run, not by this visit.
182
207
  bySlug.set(audience.slug, audience.mixed ? 'mixed' : 'session');
@@ -95,6 +95,12 @@ function emptySignals() {
95
95
  // read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
96
96
  impressions: {},
97
97
  browsingCategory: null,
98
+ // Beside each `recentBrowse` entry, the category of the listing it came from (two levels,
99
+ // `categoryKey` spelling, null for none); the listing being browsed; and listing views per
100
+ // category. What `browse_categories` is checked against when audiences are resolved.
101
+ recentBrowseCategories: [],
102
+ browsingListing: null,
103
+ listViewsByCategory: {},
98
104
  startedAt: 0,
99
105
  };
100
106
  }
package/src/session.js CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  } from './session/category.js';
15
15
  export { recordSessionEvent } from './session/record.js';
16
16
  export {
17
+ catalogueBrowse,
17
18
  evaluateSessionRule,
18
19
  measureField,
19
20
  resolveAudienceMemberships,
package/src/settings.js CHANGED
@@ -51,6 +51,19 @@ export function resolveApiHost(host) {
51
51
  : apiOrigin(host);
52
52
  }
53
53
 
54
+ /**
55
+ * Where events go. An `apiHost` the integrator set is a first-party proxy and carries events too;
56
+ * the settings' host applies only when none was set, and only over https (as the app SDKs do).
57
+ */
58
+ export function eventsHost(configured, fromSettings) {
59
+ const hasConfigured =
60
+ configured !== undefined && configured !== null && configured !== '';
61
+ if (hasConfigured) return resolveApiHost(configured);
62
+ return fromSettings?.startsWith('https://')
63
+ ? fromSettings
64
+ : resolveApiHost(undefined);
65
+ }
66
+
54
67
  /**
55
68
  * An API host as an origin. The settings give a bare host (`api.example.com`, the OpenAPI server
56
69
  * variable); an integrator may write either. Anything else is not a host.
@@ -143,6 +156,16 @@ function readAnonymousId(raw) {
143
156
  : null;
144
157
  }
145
158
 
159
+ /**
160
+ * Whether a 2xx body is a settings answer at all: an `hp` block saying on or off. Anything else (a
161
+ * captive portal's page, a proxy's error) is no answer, so the last good settings and what was
162
+ * carried are kept, as on Android, iOS and React Native.
163
+ */
164
+ export function isSettingsAnswer(body) {
165
+ const hp = isObject(body) ? body.hp : null;
166
+ return isObject(hp) && typeof hp.enabled === 'boolean';
167
+ }
168
+
146
169
  /**
147
170
  * The settings response as the SDK uses it, or DISABLED for anything that does not switch it on.
148
171
  * Unknown fields are ignored, so the API can add to the response without breaking a deployed SDK.
@@ -191,7 +214,8 @@ export async function fetchSettings(
191
214
  headers: { accept: 'application/json' },
192
215
  });
193
216
  if (!res.ok) return null;
194
- return readSettings(await res.json());
217
+ const body = await res.json();
218
+ return isSettingsAnswer(body) ? readSettings(body) : null;
195
219
  } catch {
196
220
  return null;
197
221
  }
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.10';
4
+ export const SPEC_VERSION = '1.15';
package/src/transport.js CHANGED
@@ -32,12 +32,30 @@ const DEFAULTS = {
32
32
  };
33
33
 
34
34
  const now = () => Date.now();
35
+ const MAX_BACKOFF_MS = 30_000;
36
+
37
+ /** The largest event the SDK sends: with its batch envelope it still fits one keepalive request. */
38
+ export const MAX_EVENT_BYTES = 55 * 1024;
39
+
40
+ /**
41
+ * Retry-After as milliseconds from now: delay-seconds or an HTTP date (RFC 9110 §10.2.3); 0 when
42
+ * absent or unreadable. Cross-origin, the browser exposes it only if the ingest lists it in
43
+ * Access-Control-Expose-Headers; a first-party proxy always can.
44
+ */
45
+ export function retryAfterMs(value, at = now()) {
46
+ if (typeof value !== 'string') return 0;
47
+ const text = value.trim();
48
+ if (/^\d+$/.test(text)) return Number(text) * 1000;
49
+ if (text === '') return 0;
50
+ const date = Date.parse(text);
51
+ return Number.isNaN(date) ? 0 : Math.max(0, date - at);
52
+ }
35
53
 
36
54
  /**
37
55
  * UTF-8 bytes, the unit of sendBeacon's ceiling and the keepalive budget. String length counts
38
56
  * UTF-16 code units and undercounts any non-ASCII catalogue.
39
57
  */
40
- const byteLength = (s) =>
58
+ export const byteLength = (s) =>
41
59
  typeof TextEncoder !== 'undefined'
42
60
  ? new TextEncoder().encode(s).length
43
61
  : Buffer.byteLength(s);
@@ -77,6 +95,12 @@ export class Transport {
77
95
  this.inflight = null;
78
96
  // Ids of persisted entries this instance is already sending, so a replay never races them.
79
97
  this.sending = new Set();
98
+ /** Bumped by a refusal: batches cut before it are not sent or retried after it. */
99
+ this.epoch = 0;
100
+ /** Persisted ids of the batches cut since the last refusal: the withdrawal's among them. */
101
+ this.sinceRefusal = new Set();
102
+ /** End backoff waits early, so a refusal is not held behind a retry. */
103
+ this.wakers = new Set();
80
104
  this.replayAllowed = !this.o.deferReplay;
81
105
  this.draining = null;
82
106
 
@@ -105,12 +129,31 @@ export class Transport {
105
129
  this.replayAllowed = false;
106
130
  if (!this.storage) return;
107
131
  try {
108
- this.storage.removeItem(this.o.storageKey);
132
+ // A batch cut since the refusal is the withdrawal: it keeps its saved copy until delivered.
133
+ const kept = this.#readPersisted().filter((entry) =>
134
+ this.sinceRefusal.has(entry?.id),
135
+ );
136
+ if (kept.length)
137
+ this.storage.setItem(this.o.storageKey, JSON.stringify(kept));
138
+ else this.storage.removeItem(this.o.storageKey);
109
139
  } catch {
110
140
  /* nothing was readable, so nothing will be replayed either */
111
141
  }
112
142
  }
113
143
 
144
+ /**
145
+ * Drop every event queued in memory, and stop sending or retrying the batches already cut from
146
+ * it. For a refusal: what was collected under the permission is not sent after it is withdrawn.
147
+ * The batch already on the wire cannot be recalled.
148
+ */
149
+ discardQueued() {
150
+ this.queue = [];
151
+ this.epoch += 1;
152
+ this.sinceRefusal.clear();
153
+ this.#clearTimer();
154
+ for (const wake of this.wakers) wake();
155
+ }
156
+
114
157
  /**
115
158
  * Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
116
159
  * throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
@@ -221,12 +264,22 @@ export class Transport {
221
264
  ).map((batch) => {
222
265
  const id = this.#persist(batch);
223
266
  // Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
224
- if (id) this.sending.add(id);
267
+ if (id) {
268
+ this.sending.add(id);
269
+ this.sinceRefusal.add(id);
270
+ }
225
271
  return { batch, id };
226
272
  });
227
273
  this.queue = [];
274
+ const epoch = this.epoch;
228
275
  this.inflight = (async () => {
229
- for (const { batch, id } of batches) await this.#send(batch, 0, id);
276
+ for (const { batch, id } of batches) {
277
+ if (epoch !== this.epoch) {
278
+ this.#settle(id);
279
+ continue;
280
+ }
281
+ await this.#send(batch, 0, id, epoch);
282
+ }
230
283
  })().finally(() => {
231
284
  this.inflight = null;
232
285
  if (this.queue.length >= this.o.flushAt) this.flush();
@@ -279,9 +332,10 @@ export class Transport {
279
332
  * The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
280
333
  * stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
281
334
  */
282
- async #send(batch, attempt, persisted = null) {
335
+ async #send(batch, attempt, persisted = null, epoch = this.epoch) {
283
336
  if (persisted) this.sending.add(persisted);
284
337
  const body = this.#body(batch);
338
+ let wait = 0;
285
339
  try {
286
340
  const res = await fetch(this.#url(), {
287
341
  method: 'POST',
@@ -297,9 +351,11 @@ export class Transport {
297
351
  this.#drainPersisted();
298
352
  return true;
299
353
  }
300
- // 4xx (except 429) means the payload is wrong; retrying cannot fix it and
354
+ // 4xx (except 408 and 429) means the payload is wrong; retrying cannot fix it and
301
355
  // would loop forever. Drop and surface.
302
- const retryable = res.status === 429 || res.status >= 500;
356
+ const retryable =
357
+ res.status === 408 || res.status === 429 || res.status >= 500;
358
+ if (retryable) wait = retryAfterMs(res.headers?.get?.('retry-after'));
303
359
  if (!retryable) {
304
360
  this.#settle(persisted);
305
361
  this.o.onError?.(
@@ -318,16 +374,33 @@ export class Transport {
318
374
  );
319
375
  return false;
320
376
  }
321
- const delay =
322
- Math.min(1000 * 2 ** attempt, 30_000) * (0.5 + Math.random());
323
- await new Promise((r) => setTimeout(r, delay));
324
- return this.#send(batch, attempt + 1, persisted);
377
+ // The ingest's Retry-After is a floor on the backoff, within the same cap.
378
+ const delay = Math.max(
379
+ Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS) * (0.5 + Math.random()),
380
+ Math.min(wait, MAX_BACKOFF_MS),
381
+ );
382
+ if (epoch === this.epoch)
383
+ await new Promise((r) => {
384
+ const wake = () => {
385
+ clearTimeout(timer);
386
+ this.wakers.delete(wake);
387
+ r();
388
+ };
389
+ const timer = setTimeout(wake, delay);
390
+ this.wakers.add(wake);
391
+ });
392
+ if (epoch !== this.epoch) {
393
+ this.#settle(persisted);
394
+ return false;
395
+ }
396
+ return this.#send(batch, attempt + 1, persisted, epoch);
325
397
  }
326
398
  }
327
399
 
328
400
  #settle(id) {
329
401
  if (!id) return;
330
402
  this.sending.delete(id);
403
+ this.sinceRefusal.delete(id);
331
404
  this.#unpersist(id);
332
405
  }
333
406
 
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.18.0';
1
+ export const VERSION = '3.22.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -45,7 +45,7 @@ export interface SettingsResponse {
45
45
  plan?: { track?: Record<string, { enabled?: boolean }> };
46
46
  hp?: {
47
47
  enabled: boolean;
48
- /** Null for a source scoped to several properties: the page passes `propertyId`. */
48
+ /** The source's default property, or null. A `propertyId` the page passes wins over it. */
49
49
  propertyId?: string | null;
50
50
  environment?: 'production' | 'staging' | 'development';
51
51
  /** Strict is the only mode. `purposes`: the ones this property asks for, and so reports. */
@@ -1,9 +1,7 @@
1
- export type ConsentPurpose =
2
- | 'analytics'
3
- | 'marketing'
4
- | 'personalisation'
5
- | 'ad_user_data'
6
- | 'ad_personalization';
1
+ import type { CdpConsentPurpose } from './events';
2
+
3
+ /** The purposes a visitor answers for: the spec's, generated. */
4
+ export type ConsentPurpose = CdpConsentPurpose;
7
5
 
8
6
  /** schemas/common/consent-state.json: true granted, false refused, null never asked. */
9
7
  export type ConsentState = Record<ConsentPurpose, boolean | null>;