@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
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live conformance checking: does this integration work, on this page, right now.
|
|
3
|
+
*
|
|
4
|
+
* Checks what a validator cannot:
|
|
5
|
+
* 1. What a page of this type should have emitted and did not (`page_expectations` in the
|
|
6
|
+
* registry): absence is the commoner and costlier failure. An event `required_on_entry` is
|
|
7
|
+
* owed only by the first page of a run of pages of one type — checkout owes Checkout Started
|
|
8
|
+
* when the shopper enters it, and Checkout Step Viewed on every step.
|
|
9
|
+
* 2. Whether the events that did fire would survive the ingest: required fields, producers,
|
|
10
|
+
* types, enums, and unknown properties (every event schema is `additionalProperties: false`),
|
|
11
|
+
* inside `products[]` as well, against the product version the event holds.
|
|
12
|
+
* 3. What the ingest accepts and the warehouse then cannot use: priced products with no
|
|
13
|
+
* currency, and categories that are not `/` paths.
|
|
14
|
+
*
|
|
15
|
+
* Off by default, and observe-only: it cannot alter, delay or drop an event.
|
|
16
|
+
*
|
|
17
|
+
* import { createConformanceChecker } from '@thg-agentic/hyper-personalisation-web/conformance';
|
|
18
|
+
* import rules from '@thg-agentic/hyper-personalisation-spec/generated/sdk/conformance-rules.json';
|
|
19
|
+
*
|
|
20
|
+
* const checker = createConformanceChecker({ rules, producer: 'web' });
|
|
21
|
+
* createAnalytics({ ...config, onEvent: checker.observe });
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How long after the last event a page is considered to have settled. */
|
|
25
|
+
const SETTLE_MS = 2500;
|
|
26
|
+
|
|
27
|
+
const SEVERITY = { error: 3, warning: 2, info: 1 };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The previous page's type, so a full page load can tell entering a run of pages from continuing
|
|
31
|
+
* one. Per tab and per origin, which is the right scope: a checkout on its own origin starts with
|
|
32
|
+
* nothing stored, and its first page is the entry.
|
|
33
|
+
*/
|
|
34
|
+
const PAGE_TYPE_KEY = 'thg_hp_conformance_page_type';
|
|
35
|
+
|
|
36
|
+
function defaultStorage() {
|
|
37
|
+
try {
|
|
38
|
+
return globalThis.sessionStorage ?? null;
|
|
39
|
+
} catch {
|
|
40
|
+
// Storage access throws where the browser blocks it; the checker then sees one page load.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readStored(storage) {
|
|
46
|
+
try {
|
|
47
|
+
return storage?.getItem(PAGE_TYPE_KEY) ?? null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function store(storage, pageType) {
|
|
54
|
+
try {
|
|
55
|
+
storage?.setItem(PAGE_TYPE_KEY, pageType);
|
|
56
|
+
} catch {
|
|
57
|
+
/* a full or blocked store only costs the entry check its memory */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const typeOf = (value) => {
|
|
62
|
+
if (value === null) return 'null';
|
|
63
|
+
if (Array.isArray(value)) return 'array';
|
|
64
|
+
if (Number.isInteger(value)) return 'integer';
|
|
65
|
+
return typeof value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Does a value satisfy a rule's declared primitive type? */
|
|
69
|
+
function typeMatches(expected, value) {
|
|
70
|
+
const actual = typeOf(value);
|
|
71
|
+
if (actual === expected) return true;
|
|
72
|
+
// An integer is an acceptable number; the reverse is not.
|
|
73
|
+
if (expected === 'number' && actual === 'integer') return true;
|
|
74
|
+
if (expected === 'object' && actual === 'object') return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {object} options.rules generated/sdk/conformance-rules.json
|
|
81
|
+
* @param {string} [options.producer] Which producer this integration claims to be.
|
|
82
|
+
* @param {(problem: object) => void} [options.onProblem] Called as problems are found.
|
|
83
|
+
* @param {boolean} [options.log] Print a grouped summary to the console when the page settles.
|
|
84
|
+
* @param {number} [options.settleMs]
|
|
85
|
+
* @param {Storage | null} [options.storage] Where the previous page's type is kept across full page
|
|
86
|
+
* loads. sessionStorage by default.
|
|
87
|
+
*/
|
|
88
|
+
export function createConformanceChecker(options) {
|
|
89
|
+
const {
|
|
90
|
+
rules,
|
|
91
|
+
producer = 'web',
|
|
92
|
+
onProblem,
|
|
93
|
+
log = true,
|
|
94
|
+
settleMs = SETTLE_MS,
|
|
95
|
+
storage = defaultStorage(),
|
|
96
|
+
} = options ?? {};
|
|
97
|
+
|
|
98
|
+
if (!rules?.events) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'createConformanceChecker: rules must be the generated conformance-rules.json',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const problems = [];
|
|
105
|
+
const seen = new Map();
|
|
106
|
+
let pageType = null;
|
|
107
|
+
/** The page type before this one: this page load's, or the last load's in this tab. */
|
|
108
|
+
let previousPageType = readStored(storage);
|
|
109
|
+
/** Whether the current page began a run of pages of its type. */
|
|
110
|
+
let entered = false;
|
|
111
|
+
let settleTimer = null;
|
|
112
|
+
let settled = false;
|
|
113
|
+
/** Events named as missing when the summary was printed, so their arrival can be retracted. */
|
|
114
|
+
let outstanding = new Set();
|
|
115
|
+
|
|
116
|
+
const add = (severity, kind, message, detail) => {
|
|
117
|
+
const problem = { severity, kind, message, ...detail };
|
|
118
|
+
problems.push(problem);
|
|
119
|
+
try {
|
|
120
|
+
onProblem?.(problem);
|
|
121
|
+
} catch {
|
|
122
|
+
/* an observer's failure is not the integration's problem */
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** The envelope, whose failures break every event rather than one. */
|
|
127
|
+
function checkEnvelope(event) {
|
|
128
|
+
for (const key of rules.envelope_required ?? []) {
|
|
129
|
+
// `properties` is required per-event, not on identify/page, so it is checked with the
|
|
130
|
+
// event rather than here.
|
|
131
|
+
if (key === 'properties') continue;
|
|
132
|
+
if (event[key] === undefined || event[key] === null) {
|
|
133
|
+
add(
|
|
134
|
+
'error',
|
|
135
|
+
'envelope_missing',
|
|
136
|
+
`Envelope is missing "${key}" — this quarantines EVERY event, not just this one.`,
|
|
137
|
+
{
|
|
138
|
+
event: event.event ?? event.type,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const key of rules.context_required ?? []) {
|
|
144
|
+
if (event.context?.[key] === undefined || event.context?.[key] === null) {
|
|
145
|
+
add(
|
|
146
|
+
'error',
|
|
147
|
+
'context_missing',
|
|
148
|
+
`context.${key} is missing — this quarantines EVERY event, not just this one.`,
|
|
149
|
+
{
|
|
150
|
+
event: event.event ?? event.type,
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!event.anonymousId && !event.userId) {
|
|
156
|
+
add(
|
|
157
|
+
'error',
|
|
158
|
+
'no_identity',
|
|
159
|
+
'Event carries neither anonymousId nor userId, so it cannot be attributed to anybody.',
|
|
160
|
+
{
|
|
161
|
+
event: event.event ?? event.type,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (event.context?.consent && event.context.consent.analytics === null) {
|
|
166
|
+
// Not an error: a slow CMP produces this and it resolves itself. Surfaced because a CMP that
|
|
167
|
+
// never answers looks identical.
|
|
168
|
+
add(
|
|
169
|
+
'info',
|
|
170
|
+
'consent_unknown',
|
|
171
|
+
'Consent was still unknown when this event was collected. Fine if the CMP later answers; total data loss under strict consent if it never does.',
|
|
172
|
+
{
|
|
173
|
+
event: event.event ?? event.type,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkTrack(event) {
|
|
180
|
+
const name = event.event;
|
|
181
|
+
const rule = rules.events[name];
|
|
182
|
+
|
|
183
|
+
if (!rule) {
|
|
184
|
+
add(
|
|
185
|
+
'error',
|
|
186
|
+
'unregistered_event',
|
|
187
|
+
`"${name}" is not in the registry, so the ingest will reject it. Add it to the spec, or send it as "<namespace>.<Name>" under a registered extension namespace.`,
|
|
188
|
+
{ event: name },
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!rule.producers.includes(producer)) {
|
|
194
|
+
add(
|
|
195
|
+
'error',
|
|
196
|
+
'producer_not_permitted',
|
|
197
|
+
`A "${producer}" producer may not emit "${name}" — the registry permits ${rule.producers.join(', ')}. This is usually a sign the event belongs in another codebase.`,
|
|
198
|
+
{ event: name },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const properties = event.properties ?? {};
|
|
203
|
+
|
|
204
|
+
for (const key of rule.required) {
|
|
205
|
+
const value = properties[key];
|
|
206
|
+
// An empty array is missing only where the schema demands items: an emptied basket
|
|
207
|
+
// (Cart Updated) and a listing with no results (Product List Viewed) are real, empty facts.
|
|
208
|
+
const minItems = rule.properties[key]?.minItems ?? 0;
|
|
209
|
+
if (
|
|
210
|
+
value === undefined ||
|
|
211
|
+
value === null ||
|
|
212
|
+
(Array.isArray(value) && value.length < minItems)
|
|
213
|
+
) {
|
|
214
|
+
add(
|
|
215
|
+
'error',
|
|
216
|
+
'missing_required',
|
|
217
|
+
`"${name}" is missing required property "${key}".`,
|
|
218
|
+
{ event: name, property: key },
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
224
|
+
const spec = rule.properties[key];
|
|
225
|
+
if (!spec) {
|
|
226
|
+
add(
|
|
227
|
+
'error',
|
|
228
|
+
'unknown_property',
|
|
229
|
+
`"${name}" carries "${key}", which is not in its schema. Every event sets additionalProperties:false, so this is a rejection rather than an extra field.`,
|
|
230
|
+
{ event: name, property: key },
|
|
231
|
+
);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (value === null || value === undefined) continue;
|
|
235
|
+
if (!typeMatches(spec.type, value)) {
|
|
236
|
+
add(
|
|
237
|
+
'error',
|
|
238
|
+
'wrong_type',
|
|
239
|
+
`"${name}.${key}" should be ${spec.type} but is ${typeOf(value)}.`,
|
|
240
|
+
{ event: name, property: key },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (spec.enum && !spec.enum.includes(value)) {
|
|
244
|
+
const allowed = spec.enum.filter((v) => v !== null).join(', ');
|
|
245
|
+
add(
|
|
246
|
+
'error',
|
|
247
|
+
'bad_enum',
|
|
248
|
+
`"${name}.${key}" is "${value}", which is not one of: ${allowed}.`,
|
|
249
|
+
{ event: name, property: key },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
checkProducts(name, rule, event, properties);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Inside `products[]`, which the checks above see as one array. A malformed item rejects the
|
|
259
|
+
* whole event. A price with no currency, or a category that is not a `/` path, is accepted and
|
|
260
|
+
* then quietly wasted, so those are warnings, each reported once per event.
|
|
261
|
+
*/
|
|
262
|
+
function checkProducts(name, rule, event, properties) {
|
|
263
|
+
const product = rule.product ? rules.products?.[rule.product] : null;
|
|
264
|
+
const items = properties.products;
|
|
265
|
+
if (!product || !Array.isArray(items)) return;
|
|
266
|
+
|
|
267
|
+
let priced = false;
|
|
268
|
+
let notAPath = null;
|
|
269
|
+
items.forEach((item, i) => {
|
|
270
|
+
const at = `"${name}.products[${i}]"`;
|
|
271
|
+
if (typeOf(item) !== 'object') {
|
|
272
|
+
add(
|
|
273
|
+
'error',
|
|
274
|
+
'product_wrong_type',
|
|
275
|
+
`${at} should be an object but is ${typeOf(item)}.`,
|
|
276
|
+
{
|
|
277
|
+
event: name,
|
|
278
|
+
property: 'products',
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
for (const key of product.required) {
|
|
284
|
+
if (item[key] === undefined || item[key] === null) {
|
|
285
|
+
add(
|
|
286
|
+
'error',
|
|
287
|
+
'product_missing_required',
|
|
288
|
+
`${at} is missing required "${key}", which rejects the whole event.`,
|
|
289
|
+
{ event: name, property: `products.${key}` },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const [key, value] of Object.entries(item)) {
|
|
294
|
+
const spec = product.properties[key];
|
|
295
|
+
if (!spec) {
|
|
296
|
+
add(
|
|
297
|
+
'error',
|
|
298
|
+
'product_unknown_property',
|
|
299
|
+
`${at} carries "${key}", which this event's product schema does not have. It sets additionalProperties:false, so the whole event is rejected.`,
|
|
300
|
+
{ event: name, property: `products.${key}` },
|
|
301
|
+
);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (value === null || value === undefined) continue;
|
|
305
|
+
if (!typeMatches(spec.type, value)) {
|
|
306
|
+
add(
|
|
307
|
+
'error',
|
|
308
|
+
'product_wrong_type',
|
|
309
|
+
`${at}.${key} should be ${spec.type} but is ${typeOf(value)}.`,
|
|
310
|
+
{ event: name, property: `products.${key}` },
|
|
311
|
+
);
|
|
312
|
+
} else if (spec.enum && !spec.enum.includes(value)) {
|
|
313
|
+
const allowed = spec.enum.filter((v) => v !== null).join(', ');
|
|
314
|
+
add(
|
|
315
|
+
'error',
|
|
316
|
+
'product_bad_enum',
|
|
317
|
+
`${at}.${key} is "${value}", which is not one of: ${allowed}.`,
|
|
318
|
+
{ event: name, property: `products.${key}` },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (typeof item.price === 'number') priced = true;
|
|
323
|
+
if (
|
|
324
|
+
notAPath === null &&
|
|
325
|
+
typeof item.category === 'string' &&
|
|
326
|
+
/[>|]/.test(item.category) &&
|
|
327
|
+
!item.category.includes('/')
|
|
328
|
+
) {
|
|
329
|
+
notAPath = item.category;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const named = (value) => typeof value === 'string' && value.trim() !== '';
|
|
334
|
+
if (
|
|
335
|
+
priced &&
|
|
336
|
+
!named(properties.currency) &&
|
|
337
|
+
!named(event.context?.currency)
|
|
338
|
+
) {
|
|
339
|
+
add(
|
|
340
|
+
'warning',
|
|
341
|
+
'price_without_currency',
|
|
342
|
+
`"${name}" prices its products but names no currency, neither its own "currency" nor context.currency. It is accepted, but the warehouse cannot convert the amounts, so they drop out of revenue and average price. Set the "currency" or "getCurrency" option.`,
|
|
343
|
+
{ event: name, property: 'currency' },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (notAPath !== null) {
|
|
347
|
+
add(
|
|
348
|
+
'warning',
|
|
349
|
+
'category_not_a_path',
|
|
350
|
+
`"${name}" sends the category "${notAPath}". Categories are read as "/" paths, so this counts as one level in category interest and session audiences. Send "Women/Shoes/Boots".`,
|
|
351
|
+
{ event: name, property: 'products.category' },
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* What this page still owes, recomputed on every call rather than stored: an impression waits on
|
|
358
|
+
* a scroll and a rail on a fetch, so a missing event stops being reported the moment it arrives.
|
|
359
|
+
*/
|
|
360
|
+
function expectationProblems() {
|
|
361
|
+
if (!pageType) {
|
|
362
|
+
return [
|
|
363
|
+
{
|
|
364
|
+
severity: 'warning',
|
|
365
|
+
kind: 'no_page_type',
|
|
366
|
+
message:
|
|
367
|
+
'No page view was observed, so nothing could be checked for completeness. Call analytics.page({ type }) on every page.',
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const expectation = rules.page_expectations?.[pageType];
|
|
373
|
+
if (!expectation) return [];
|
|
374
|
+
|
|
375
|
+
const missing = [];
|
|
376
|
+
for (const name of expectation.required ?? []) {
|
|
377
|
+
if (!seen.has(name)) {
|
|
378
|
+
missing.push({
|
|
379
|
+
severity: 'error',
|
|
380
|
+
kind: 'missing_expected_event',
|
|
381
|
+
message: `A "${pageType}" page must emit "${name}" and did not. Nothing invalid was sent — the event simply never fired, which is why schema validation cannot catch this.`,
|
|
382
|
+
event: name,
|
|
383
|
+
pageType,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const name of entered ? (expectation.required_on_entry ?? []) : []) {
|
|
388
|
+
if (!seen.has(name)) {
|
|
389
|
+
missing.push({
|
|
390
|
+
severity: 'error',
|
|
391
|
+
kind: 'missing_entry_event',
|
|
392
|
+
message: `Entering a "${pageType}" page from elsewhere must emit "${name}" and did not. It is owed once, by the first page of the run, not by every page.`,
|
|
393
|
+
event: name,
|
|
394
|
+
pageType,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const name of expectation.recommended ?? []) {
|
|
399
|
+
if (!seen.has(name)) {
|
|
400
|
+
missing.push({
|
|
401
|
+
severity: 'warning',
|
|
402
|
+
kind: 'missing_recommended_event',
|
|
403
|
+
message: `A "${pageType}" page usually emits "${name}". Still absent — legitimate if the surface is below the fold and nobody scrolled to it.`,
|
|
404
|
+
event: name,
|
|
405
|
+
pageType,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return missing;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Print the summary once the page has gone quiet.
|
|
414
|
+
*
|
|
415
|
+
* The print is a snapshot and the object is live: anything that arrives afterwards updates
|
|
416
|
+
* `report()`, and a previously-missing event that turns up is announced so the console does not
|
|
417
|
+
* leave a retracted warning as the last word on screen.
|
|
418
|
+
*/
|
|
419
|
+
function checkExpectations() {
|
|
420
|
+
if (settled) return;
|
|
421
|
+
settled = true;
|
|
422
|
+
outstanding = new Set(
|
|
423
|
+
expectationProblems()
|
|
424
|
+
.map((p) => p.event)
|
|
425
|
+
.filter(Boolean),
|
|
426
|
+
);
|
|
427
|
+
if (log) print();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function print() {
|
|
431
|
+
const report = summary();
|
|
432
|
+
const style = report.ok ? 'color:#0e7a4b' : 'color:#b3261e';
|
|
433
|
+
const label = report.ok
|
|
434
|
+
? `CDP conformance: ${pageType} page OK (${report.eventCount} events)`
|
|
435
|
+
: `CDP conformance: ${report.errors} error(s), ${report.warnings} warning(s) on a ${pageType} page`;
|
|
436
|
+
|
|
437
|
+
// groupCollapsed rather than a wall of warnings: this runs on every page of a dev session,
|
|
438
|
+
// and a checker that buries the actual console is one people turn off.
|
|
439
|
+
console.groupCollapsed(`%c${label}`, `${style};font-weight:600`);
|
|
440
|
+
for (const p of report.problems) {
|
|
441
|
+
const fn =
|
|
442
|
+
p.severity === 'error'
|
|
443
|
+
? console.error
|
|
444
|
+
: p.severity === 'warning'
|
|
445
|
+
? console.warn
|
|
446
|
+
: console.info;
|
|
447
|
+
fn(`[${p.kind}] ${p.message}`);
|
|
448
|
+
}
|
|
449
|
+
console.info('Events seen:', Object.fromEntries(seen));
|
|
450
|
+
console.groupEnd();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function summary() {
|
|
454
|
+
// Validation problems are historical facts and are stored; missing-event problems are a
|
|
455
|
+
// statement about right now and are recomputed. See expectationProblems.
|
|
456
|
+
const all = [...problems, ...expectationProblems()];
|
|
457
|
+
const ordered = [...all].sort(
|
|
458
|
+
(a, b) => SEVERITY[b.severity] - SEVERITY[a.severity],
|
|
459
|
+
);
|
|
460
|
+
const errors = all.filter((p) => p.severity === 'error').length;
|
|
461
|
+
const warnings = all.filter((p) => p.severity === 'warning').length;
|
|
462
|
+
return {
|
|
463
|
+
ok: errors === 0,
|
|
464
|
+
pageType,
|
|
465
|
+
entered,
|
|
466
|
+
specVersion: rules.spec_version,
|
|
467
|
+
eventCount: [...seen.values()].reduce((n, c) => n + c, 0),
|
|
468
|
+
seen: Object.fromEntries(seen),
|
|
469
|
+
errors,
|
|
470
|
+
warnings,
|
|
471
|
+
problems: ordered,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const checker = {
|
|
476
|
+
/** Hand this to createAnalytics as `onEvent`. */
|
|
477
|
+
observe(event) {
|
|
478
|
+
try {
|
|
479
|
+
const name = event.event ?? event.type;
|
|
480
|
+
seen.set(name, (seen.get(name) ?? 0) + 1);
|
|
481
|
+
if (event.type === 'page') {
|
|
482
|
+
if (pageType !== null) previousPageType = pageType;
|
|
483
|
+
pageType = event.context?.page?.type ?? null;
|
|
484
|
+
entered = pageType !== previousPageType;
|
|
485
|
+
if (pageType !== null) store(storage, pageType);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// An event the summary already called missing has now turned up. Say so, or the console's
|
|
489
|
+
// last word on this page is a warning that is no longer true.
|
|
490
|
+
if (outstanding.delete(name) && log) {
|
|
491
|
+
console.info(
|
|
492
|
+
`%cCDP conformance: "${name}" arrived after the page settled — no longer missing.`,
|
|
493
|
+
'color:#0e7a4b',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
checkEnvelope(event);
|
|
498
|
+
if (event.type === 'track') checkTrack(event);
|
|
499
|
+
|
|
500
|
+
clearTimeout(settleTimer);
|
|
501
|
+
settleTimer = setTimeout(checkExpectations, settleMs);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
// The checker must never be the reason an event fails to send.
|
|
504
|
+
console.warn('[hp] conformance checker failed', error);
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/** Force the completeness check now, rather than waiting for the page to go quiet. */
|
|
509
|
+
finish() {
|
|
510
|
+
clearTimeout(settleTimer);
|
|
511
|
+
checkExpectations();
|
|
512
|
+
return summary();
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
report: summary,
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
if (typeof window !== 'undefined') {
|
|
519
|
+
// The handle the CI harness reads. Also the fastest way for a developer to ask "what did
|
|
520
|
+
// this page actually send" without a network tab full of batched beacons.
|
|
521
|
+
window.__hpConformance = checker;
|
|
522
|
+
// A visitor who leaves before the page settles still gets checked, which matters because a
|
|
523
|
+
// fast bounce is precisely when a late event is missed.
|
|
524
|
+
window.addEventListener('pagehide', () => checkExpectations(), {
|
|
525
|
+
once: true,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return checker;
|
|
530
|
+
}
|