@thg-agentic/hyper-personalisation-web 3.19.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.
- package/README.md +44 -12
- package/dist/hp.min.js +3 -3
- package/package.json +2 -2
- package/src/bootstrap.js +4 -3
- package/src/context.js +0 -2
- package/src/index.js +81 -19
- 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/src/index.js
CHANGED
|
@@ -183,6 +183,64 @@ export function normaliseSha256(value) {
|
|
|
183
183
|
return hex === EMPTY_SHA256 ? null : hex;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** The identify traits the spec defines (core/identify, `additionalProperties: false`), by type. */
|
|
187
|
+
export const IDENTIFY_TRAITS = Object.freeze({
|
|
188
|
+
email_sha256: 'sha256',
|
|
189
|
+
phone_sha256: 'sha256',
|
|
190
|
+
accepts_marketing: 'boolean',
|
|
191
|
+
gender: 'string',
|
|
192
|
+
created_at: 'date-time',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const RFC3339 =
|
|
196
|
+
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/i;
|
|
197
|
+
|
|
198
|
+
/** An RFC 3339 date-time naming a real day: 2026-02-30 is not one, though Date.parse rolls it on. */
|
|
199
|
+
function isDateTime(value) {
|
|
200
|
+
if (typeof value !== 'string' || !RFC3339.test(value)) return false;
|
|
201
|
+
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
|
202
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
203
|
+
return (
|
|
204
|
+
date.getUTCMonth() === month - 1 &&
|
|
205
|
+
date.getUTCDate() === day &&
|
|
206
|
+
!Number.isNaN(Date.parse(value))
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One trait as the spec takes it, or undefined when it is not one: then it is not sent. */
|
|
211
|
+
function identifyTrait(kind, value) {
|
|
212
|
+
if (value === null) return null;
|
|
213
|
+
if (kind === 'sha256') return normaliseSha256(value) ?? undefined;
|
|
214
|
+
if (kind === 'date-time') {
|
|
215
|
+
if (value instanceof Date)
|
|
216
|
+
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
|
|
217
|
+
return isDateTime(value) ? value : undefined;
|
|
218
|
+
}
|
|
219
|
+
return typeof value === kind ? value : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Only the traits the spec defines, each of its type: a raw email or phone never leaves the page, and
|
|
224
|
+
* an identify is never quarantined for an extra key. Digests in hex or base64 are sent as hex.
|
|
225
|
+
*/
|
|
226
|
+
function identifyTraits(traits, debug) {
|
|
227
|
+
const kept = {};
|
|
228
|
+
const dropped = [];
|
|
229
|
+
for (const [key, value] of Object.entries(traits)) {
|
|
230
|
+
const kind = Object.hasOwn(IDENTIFY_TRAITS, key)
|
|
231
|
+
? IDENTIFY_TRAITS[key]
|
|
232
|
+
: null;
|
|
233
|
+
const trait = kind ? identifyTrait(kind, value) : undefined;
|
|
234
|
+
if (trait === undefined) dropped.push(key);
|
|
235
|
+
else kept[key] = trait;
|
|
236
|
+
}
|
|
237
|
+
if (debug && dropped.length)
|
|
238
|
+
console.warn(
|
|
239
|
+
`[hp] identify: not sent, not spec traits: ${dropped.join(', ')}`,
|
|
240
|
+
);
|
|
241
|
+
return kept;
|
|
242
|
+
}
|
|
243
|
+
|
|
186
244
|
// On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
|
|
187
245
|
export {
|
|
188
246
|
assignRecommendationVariant,
|
|
@@ -375,6 +433,10 @@ export function createAnalytics(options) {
|
|
|
375
433
|
function compose(partial, ctxOverrides, force) {
|
|
376
434
|
const made = replaying;
|
|
377
435
|
replaying = null;
|
|
436
|
+
// The plan is keyed as the gateway keys it: a track's name, otherwise the call's type. A
|
|
437
|
+
// withdrawal is the record of the refusal, so no plan drops it.
|
|
438
|
+
const key = partial.type === 'track' ? partial.event : partial.type;
|
|
439
|
+
if (!force && !planAllows(cfg.plan, key)) return null;
|
|
378
440
|
let event = {
|
|
379
441
|
messageId: made?.messageId ?? uuid(),
|
|
380
442
|
timestamp: made?.timestamp ?? new Date().toISOString(),
|
|
@@ -514,9 +576,7 @@ export function createAnalytics(options) {
|
|
|
514
576
|
}
|
|
515
577
|
|
|
516
578
|
const track = (name, properties, ctx) =>
|
|
517
|
-
|
|
518
|
-
? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
|
|
519
|
-
: null;
|
|
579
|
+
emit({ type: 'track', event: name, properties: properties ?? {} }, ctx);
|
|
520
580
|
|
|
521
581
|
// One exposure event per serving response, however often the page reuses it.
|
|
522
582
|
const exposedTokens = new Set();
|
|
@@ -588,6 +648,8 @@ export function createAnalytics(options) {
|
|
|
588
648
|
// forgets the signals an earlier page kept under the permission.
|
|
589
649
|
if (state?.personalisation !== true)
|
|
590
650
|
forgetKeptVisit(previous?.personalisation === true);
|
|
651
|
+
// Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
|
|
652
|
+
if (withdrawal) transport.discardQueued?.();
|
|
591
653
|
emit(
|
|
592
654
|
{
|
|
593
655
|
type: 'track',
|
|
@@ -682,9 +744,19 @@ export function createAnalytics(options) {
|
|
|
682
744
|
);
|
|
683
745
|
}
|
|
684
746
|
|
|
747
|
+
/** After a sign-out is sent under the signed-in identity: clear it for whoever uses the device next. */
|
|
748
|
+
function signOut(event) {
|
|
749
|
+
if (userId) forgetVisitor();
|
|
750
|
+
userId = null;
|
|
751
|
+
identifiedUserId = null;
|
|
752
|
+
optionsUserId = null;
|
|
753
|
+
return event;
|
|
754
|
+
}
|
|
755
|
+
|
|
685
756
|
return guardMethods({
|
|
686
757
|
/** Escape hatch for events not yet in the core spec. Prefer a named method. */
|
|
687
|
-
track,
|
|
758
|
+
track: (event, p, c) =>
|
|
759
|
+
event === 'Signed Out' ? signOut(track(event, p, c)) : track(event, p, c),
|
|
688
760
|
|
|
689
761
|
page(props, ctx) {
|
|
690
762
|
props ??= {};
|
|
@@ -744,13 +816,10 @@ export function createAnalytics(options) {
|
|
|
744
816
|
userId = next;
|
|
745
817
|
if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
|
|
746
818
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
else delete rest.email_sha256;
|
|
752
|
-
}
|
|
753
|
-
return emit({ type: 'identify', traits: rest }, ctx);
|
|
819
|
+
return emit(
|
|
820
|
+
{ type: 'identify', traits: identifyTraits(rest, cfg.debug) },
|
|
821
|
+
ctx,
|
|
822
|
+
);
|
|
754
823
|
},
|
|
755
824
|
|
|
756
825
|
/**
|
|
@@ -885,14 +954,7 @@ export function createAnalytics(options) {
|
|
|
885
954
|
signedIn: (p, c) => track('Signed In', p, c),
|
|
886
955
|
signedUp: (p, c) => track('Signed Up', p, c),
|
|
887
956
|
/** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
|
|
888
|
-
signedOut(c) {
|
|
889
|
-
const event = track('Signed Out', {}, c);
|
|
890
|
-
if (userId) forgetVisitor();
|
|
891
|
-
userId = null;
|
|
892
|
-
identifiedUserId = null;
|
|
893
|
-
optionsUserId = null;
|
|
894
|
-
return event;
|
|
895
|
-
},
|
|
957
|
+
signedOut: (c) => signOut(track('Signed Out', {}, c)),
|
|
896
958
|
newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
|
|
897
959
|
/** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
|
|
898
960
|
newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
|
package/src/segment.js
CHANGED
|
@@ -33,7 +33,7 @@ import { hostConsent, reportingPurposes } from './consent.js';
|
|
|
33
33
|
import { createConsentGate } from './consent-gate.js';
|
|
34
34
|
import { buildContext, CONTEXT_KEYS } from './context.js';
|
|
35
35
|
import { deviceIdFrom } from './device-storage.js';
|
|
36
|
-
import { planAllows, readSettings
|
|
36
|
+
import { eventsHost, planAllows, readSettings } from './settings.js';
|
|
37
37
|
|
|
38
38
|
export { DEFAULT_API_HOST } from './settings.js';
|
|
39
39
|
|
|
@@ -134,12 +134,20 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
134
134
|
disableClientPersistence: true,
|
|
135
135
|
updateCDNSettings(cdnSettings) {
|
|
136
136
|
const integrations = { ...cdnSettings.integrations };
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
const block = integrations['Segment.io'];
|
|
138
|
+
// The settings' block is kept only where the settings' host would be: no apiHost set, and https.
|
|
139
|
+
const configured =
|
|
140
|
+
apiHost !== undefined && apiHost !== null && apiHost !== '';
|
|
141
|
+
if (
|
|
142
|
+
configured ||
|
|
143
|
+
!block?.apiHost ||
|
|
144
|
+
(block.protocol ?? 'https') !== 'https'
|
|
145
|
+
) {
|
|
146
|
+
const origin = eventsHost(apiHost, readSettings(cdnSettings).apiHost);
|
|
140
147
|
if (!origin) return cdnSettings;
|
|
141
148
|
const { protocol, host } = new URL(origin);
|
|
142
149
|
integrations['Segment.io'] = {
|
|
150
|
+
...block,
|
|
143
151
|
apiKey: writeKey,
|
|
144
152
|
apiHost: `${host}/v1`,
|
|
145
153
|
protocol: protocol.replace(':', ''),
|
|
@@ -156,8 +164,8 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
156
164
|
|
|
157
165
|
/**
|
|
158
166
|
* @param {object} options Same names and meanings as createAnalytics().
|
|
159
|
-
* @param {string} [options.propertyId]
|
|
160
|
-
* the
|
|
167
|
+
* @param {string} [options.propertyId] The page's property. It wins over the `hp` block's, which is
|
|
168
|
+
* the source's default property, as on Android, iOS and React Native.
|
|
161
169
|
* @param {string} [options.channel]
|
|
162
170
|
* @param {string} [options.market]
|
|
163
171
|
* @param {string} [options.environment]
|
|
@@ -181,7 +189,6 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
181
189
|
* @param {object} [options.anonymousIdSource] Only without settings: the `hp` block's wins.
|
|
182
190
|
* @param {() => string|null} [options.getPageType]
|
|
183
191
|
* @param {() => string|null} [options.getSessionId]
|
|
184
|
-
* @param {() => string|null} [options.getRenderId]
|
|
185
192
|
* @param {() => object|null} [options.getExtensions]
|
|
186
193
|
* @param {() => object[]|null} [options.getExperiments]
|
|
187
194
|
* @param {() => object[]|null} [options.getAudiences]
|
|
@@ -211,7 +218,7 @@ export function cdpPlugins(options = {}) {
|
|
|
211
218
|
if (cdnSettings?.hp !== undefined) {
|
|
212
219
|
const settings = readSettings(cdnSettings);
|
|
213
220
|
if (settings.enabled) {
|
|
214
|
-
cfg.propertyId =
|
|
221
|
+
cfg.propertyId = cfg.propertyId ?? settings.propertyId;
|
|
215
222
|
cfg.environment = settings.environment;
|
|
216
223
|
if (settings.queryParamAllowlist)
|
|
217
224
|
cfg.queryParamAllowlist = settings.queryParamAllowlist;
|
|
@@ -244,7 +251,8 @@ export function cdpPlugins(options = {}) {
|
|
|
244
251
|
delete context[WITHDRAWN_AS];
|
|
245
252
|
|
|
246
253
|
if (off) throw cancellation(ctx, 'not enabled for this write key');
|
|
247
|
-
|
|
254
|
+
const key = ctx.event.type === 'track' ? ctx.event.event : ctx.event.type;
|
|
255
|
+
if (!forced && !planAllows(cfg.plan, key)) {
|
|
248
256
|
throw cancellation(ctx, 'disabled by the tracking plan');
|
|
249
257
|
}
|
|
250
258
|
|
package/src/session/record.js
CHANGED
|
@@ -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;
|
|
@@ -20,6 +20,40 @@ const ENGAGEMENT_EVENTS = new Set([
|
|
|
20
20
|
*/
|
|
21
21
|
const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
|
|
22
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
|
+
}
|
|
56
|
+
|
|
23
57
|
/**
|
|
24
58
|
* Fold one CDP event into the session signals.
|
|
25
59
|
*
|
|
@@ -37,38 +71,64 @@ export function recordSessionEvent(eventName, properties) {
|
|
|
37
71
|
switch (eventName) {
|
|
38
72
|
case 'Product Viewed':
|
|
39
73
|
s.productViews += 1;
|
|
40
|
-
|
|
74
|
+
browsed(s, s.browsingCategory);
|
|
41
75
|
break;
|
|
42
76
|
case 'Product List Viewed':
|
|
43
77
|
// Only a category list is browsing, or a category page viewed. A rail or carousel names its
|
|
44
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
|
-
|
|
53
|
-
|
|
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;
|
|
54
101
|
}
|
|
102
|
+
s.browsingListing = { key, category: listed };
|
|
103
|
+
browsed(s, key);
|
|
104
|
+
s.browsingCategory = key;
|
|
105
|
+
countListView(s, listed);
|
|
106
|
+
s.listViews += 1;
|
|
55
107
|
}
|
|
56
108
|
break;
|
|
57
109
|
case 'Product List Filtered': {
|
|
58
|
-
// 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.
|
|
59
113
|
const change = properties?.change;
|
|
60
114
|
const list =
|
|
61
115
|
typeof properties?.list_id === 'string' ? properties.list_id : '';
|
|
62
116
|
const facet = typeof change?.value === 'string' ? change.value : '';
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
66
126
|
}
|
|
67
127
|
break;
|
|
68
128
|
}
|
|
69
129
|
case 'Product Added': {
|
|
70
130
|
s.cartAdds += 1;
|
|
71
|
-
|
|
131
|
+
browsed(s, s.browsingCategory);
|
|
72
132
|
for (const p of products) {
|
|
73
133
|
const id = p.product_id;
|
|
74
134
|
if (typeof id !== 'string' || !id) continue;
|
package/src/session/rules.js
CHANGED
|
@@ -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,
|
|
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');
|
package/src/session/signals.js
CHANGED
|
@@ -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
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
|
-
|
|
217
|
+
const body = await res.json();
|
|
218
|
+
return isSettingsAnswer(body) ? readSettings(body) : null;
|
|
195
219
|
} catch {
|
|
196
220
|
return null;
|
|
197
221
|
}
|
package/src/spec-version.js
CHANGED
package/src/transport.js
CHANGED
|
@@ -95,6 +95,12 @@ export class Transport {
|
|
|
95
95
|
this.inflight = null;
|
|
96
96
|
// Ids of persisted entries this instance is already sending, so a replay never races them.
|
|
97
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();
|
|
98
104
|
this.replayAllowed = !this.o.deferReplay;
|
|
99
105
|
this.draining = null;
|
|
100
106
|
|
|
@@ -123,12 +129,31 @@ export class Transport {
|
|
|
123
129
|
this.replayAllowed = false;
|
|
124
130
|
if (!this.storage) return;
|
|
125
131
|
try {
|
|
126
|
-
|
|
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);
|
|
127
139
|
} catch {
|
|
128
140
|
/* nothing was readable, so nothing will be replayed either */
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
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
|
+
|
|
132
157
|
/**
|
|
133
158
|
* Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
|
|
134
159
|
* throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
|
|
@@ -239,12 +264,22 @@ export class Transport {
|
|
|
239
264
|
).map((batch) => {
|
|
240
265
|
const id = this.#persist(batch);
|
|
241
266
|
// Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
|
|
242
|
-
if (id)
|
|
267
|
+
if (id) {
|
|
268
|
+
this.sending.add(id);
|
|
269
|
+
this.sinceRefusal.add(id);
|
|
270
|
+
}
|
|
243
271
|
return { batch, id };
|
|
244
272
|
});
|
|
245
273
|
this.queue = [];
|
|
274
|
+
const epoch = this.epoch;
|
|
246
275
|
this.inflight = (async () => {
|
|
247
|
-
for (const { batch, id } of batches)
|
|
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
|
+
}
|
|
248
283
|
})().finally(() => {
|
|
249
284
|
this.inflight = null;
|
|
250
285
|
if (this.queue.length >= this.o.flushAt) this.flush();
|
|
@@ -297,7 +332,7 @@ export class Transport {
|
|
|
297
332
|
* The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
|
|
298
333
|
* stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
|
|
299
334
|
*/
|
|
300
|
-
async #send(batch, attempt, persisted = null) {
|
|
335
|
+
async #send(batch, attempt, persisted = null, epoch = this.epoch) {
|
|
301
336
|
if (persisted) this.sending.add(persisted);
|
|
302
337
|
const body = this.#body(batch);
|
|
303
338
|
let wait = 0;
|
|
@@ -316,9 +351,10 @@ export class Transport {
|
|
|
316
351
|
this.#drainPersisted();
|
|
317
352
|
return true;
|
|
318
353
|
}
|
|
319
|
-
// 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
|
|
320
355
|
// would loop forever. Drop and surface.
|
|
321
|
-
const retryable =
|
|
356
|
+
const retryable =
|
|
357
|
+
res.status === 408 || res.status === 429 || res.status >= 500;
|
|
322
358
|
if (retryable) wait = retryAfterMs(res.headers?.get?.('retry-after'));
|
|
323
359
|
if (!retryable) {
|
|
324
360
|
this.#settle(persisted);
|
|
@@ -343,14 +379,28 @@ export class Transport {
|
|
|
343
379
|
Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS) * (0.5 + Math.random()),
|
|
344
380
|
Math.min(wait, MAX_BACKOFF_MS),
|
|
345
381
|
);
|
|
346
|
-
|
|
347
|
-
|
|
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);
|
|
348
397
|
}
|
|
349
398
|
}
|
|
350
399
|
|
|
351
400
|
#settle(id) {
|
|
352
401
|
if (!id) return;
|
|
353
402
|
this.sending.delete(id);
|
|
403
|
+
this.sinceRefusal.delete(id);
|
|
354
404
|
this.#unpersist(id);
|
|
355
405
|
}
|
|
356
406
|
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.22.0';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/bootstrap.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SettingsResponse {
|
|
|
45
45
|
plan?: { track?: Record<string, { enabled?: boolean }> };
|
|
46
46
|
hp?: {
|
|
47
47
|
enabled: boolean;
|
|
48
|
-
/**
|
|
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. */
|