@releval/tracker 1.0.0-bootstrap.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 +190 -0
- package/README.md +366 -0
- package/dist/ResultImpressionCollector.d.cts +762 -0
- package/dist/ResultImpressionCollector.d.mts +762 -0
- package/dist/react.cjs +180 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +139 -0
- package/dist/react.d.mts +139 -0
- package/dist/react.mjs +174 -0
- package/dist/react.mjs.map +1 -0
- package/dist/releval-tracker.cjs +8 -0
- package/dist/releval-tracker.d.cts +135 -0
- package/dist/releval-tracker.d.mts +135 -0
- package/dist/releval-tracker.global.js +2 -0
- package/dist/releval-tracker.mjs +3 -0
- package/dist/tracker.cjs +1663 -0
- package/dist/tracker.cjs.map +1 -0
- package/dist/tracker.mjs +1634 -0
- package/dist/tracker.mjs.map +1 -0
- package/package.json +131 -0
package/dist/tracker.cjs
ADDED
|
@@ -0,0 +1,1663 @@
|
|
|
1
|
+
/*! @releval/tracker | Apache-2.0 | https://github.com/releval/tracker */
|
|
2
|
+
let ulidx = require("ulidx");
|
|
3
|
+
//#region src/utils/index.ts
|
|
4
|
+
const debounce = (func, wait = 100, immediate = false) => {
|
|
5
|
+
let timeout = null;
|
|
6
|
+
let args = null;
|
|
7
|
+
let context = null;
|
|
8
|
+
let result;
|
|
9
|
+
const later = () => {
|
|
10
|
+
timeout = null;
|
|
11
|
+
if (args) {
|
|
12
|
+
result = func.apply(context, args);
|
|
13
|
+
context = args = null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const debounced = function(...callArgs) {
|
|
17
|
+
context = this;
|
|
18
|
+
args = callArgs;
|
|
19
|
+
const callNow = immediate && !timeout;
|
|
20
|
+
if (timeout) clearTimeout(timeout);
|
|
21
|
+
if (callNow) {
|
|
22
|
+
result = func.apply(context, args);
|
|
23
|
+
context = args = null;
|
|
24
|
+
} else timeout = setTimeout(later, wait);
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
debounced.clear = () => {
|
|
28
|
+
if (timeout) {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
timeout = null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
debounced.flush = () => {
|
|
34
|
+
if (timeout) {
|
|
35
|
+
result = func.apply(context, args);
|
|
36
|
+
context = args = null;
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
timeout = null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return debounced;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Creates a MutationObserver that invokes `addHandler`/`removeHandler` for every element
|
|
45
|
+
* matching `selector` that is added to or removed from the DOM - including matches nested
|
|
46
|
+
* inside an added/removed subtree. When a container is inserted or removed as a unit, only
|
|
47
|
+
* its root appears in the mutation record, so its descendants must be scanned too (otherwise
|
|
48
|
+
* results rendered as a subtree would be missed and subtree removals would leak listeners).
|
|
49
|
+
*/
|
|
50
|
+
const createMutationObserver = (selector, addHandler, removeHandler) => {
|
|
51
|
+
const forEachMatch = (node, handler) => {
|
|
52
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
53
|
+
const element = node;
|
|
54
|
+
if (element.matches(selector)) handler(element);
|
|
55
|
+
for (const descendant of element.querySelectorAll(selector)) handler(descendant);
|
|
56
|
+
};
|
|
57
|
+
return new MutationObserver((mutations) => {
|
|
58
|
+
for (const mutation of mutations) {
|
|
59
|
+
if (mutation.type !== "childList") continue;
|
|
60
|
+
mutation.addedNodes.forEach((node) => {
|
|
61
|
+
forEachMatch(node, addHandler);
|
|
62
|
+
});
|
|
63
|
+
mutation.removedNodes.forEach((node) => {
|
|
64
|
+
forEachMatch(node, removeHandler);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* A valid UBI rank: a positive integer (1-based, absolute across pages:
|
|
71
|
+
* `(page - 1) * pageSize + positionOnPage`). Anything else - 0, negative,
|
|
72
|
+
* fractional, NaN - is not a rank and must never be sent: a fabricated
|
|
73
|
+
* ordinal is indistinguishable from data downstream.
|
|
74
|
+
*/
|
|
75
|
+
const isValidOrdinal = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/collectors/resolveResult.ts
|
|
78
|
+
let hasWarnedMalformedJson = false;
|
|
79
|
+
/**
|
|
80
|
+
* A `data-event-*` value that looks like a JSON object or array is parsed as
|
|
81
|
+
* one, so markup can carry structured attributes
|
|
82
|
+
* (`data-event-filters='{"brand":"acme"}'`). Anything else - scalars
|
|
83
|
+
* included - stays a string; typed scalars use the direct API. A malformed
|
|
84
|
+
* candidate falls back to the raw string with a one-time warning.
|
|
85
|
+
*/
|
|
86
|
+
const parseAttributeValue = (raw, datasetKey, logger) => {
|
|
87
|
+
const trimmed = raw.trim();
|
|
88
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(trimmed);
|
|
91
|
+
} catch (_exception) {
|
|
92
|
+
if (!hasWarnedMalformedJson) {
|
|
93
|
+
hasWarnedMalformedJson = true;
|
|
94
|
+
const attribute = `data-${datasetKey.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
95
|
+
logger === null || logger === void 0 || logger.warn(`readResultData: the value of ${attribute} looks like JSON but does not parse; it is kept as a string. Further malformed values are kept silently.`);
|
|
96
|
+
}
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Reads the documented data-attribute convention off a result element:
|
|
102
|
+
*
|
|
103
|
+
* - `data-object-id`, `data-ordinal`, `data-object-id-field` and (optionally)
|
|
104
|
+
* `data-action-name` on the result element itself;
|
|
105
|
+
* - `data-event-*` on the result element for custom event attributes:
|
|
106
|
+
* `data-event-badge="sale"` becomes `badge: "sale"` (hyphens camelCase,
|
|
107
|
+
* underscores survive - `data-event-sale_price` -> `sale_price`); a value
|
|
108
|
+
* that looks like a JSON object or array is parsed as one
|
|
109
|
+
* (`data-event-filters='{"brand":"acme"}'`), any other value - scalars
|
|
110
|
+
* included - stays a string;
|
|
111
|
+
* - `data-query-id` and `data-query` on the nearest ancestor carrying
|
|
112
|
+
* `data-query-id` (typically the results container, rendered server-side).
|
|
113
|
+
*
|
|
114
|
+
* A missing, empty or non-positive-integer `data-ordinal` yields
|
|
115
|
+
* `undefined`, never 0 - a
|
|
116
|
+
* fabricated rank is indistinguishable from data downstream. The collectors
|
|
117
|
+
* validate what they need and warn (once) when a required field is absent.
|
|
118
|
+
*
|
|
119
|
+
* @param logger receives the one-time malformed-JSON warning; the collectors
|
|
120
|
+
* pass the tracker's logger.
|
|
121
|
+
*/
|
|
122
|
+
const readResultData = (element, logger) => {
|
|
123
|
+
var _element$dataset$ordi;
|
|
124
|
+
const container = element.closest("[data-query-id]");
|
|
125
|
+
const trimmed = (_element$dataset$ordi = element.dataset.ordinal) === null || _element$dataset$ordi === void 0 ? void 0 : _element$dataset$ordi.trim();
|
|
126
|
+
const parsed = trimmed ? Number(trimmed) : NaN;
|
|
127
|
+
const extras = {};
|
|
128
|
+
for (const key of Object.keys(element.dataset)) if (/^event[A-Z]/.test(key)) extras[key.charAt(5).toLowerCase() + key.slice(6)] = parseAttributeValue(element.dataset[key], key, logger);
|
|
129
|
+
return {
|
|
130
|
+
...extras,
|
|
131
|
+
objectId: element.dataset.objectId,
|
|
132
|
+
ordinal: isValidOrdinal(parsed) ? parsed : void 0,
|
|
133
|
+
objectIdField: element.dataset.objectIdField,
|
|
134
|
+
actionName: element.dataset.actionName,
|
|
135
|
+
queryId: container === null || container === void 0 ? void 0 : container.dataset.queryId,
|
|
136
|
+
query: container === null || container === void 0 ? void 0 : container.dataset.query
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/collectors/ResultClickCollector.ts
|
|
141
|
+
/**
|
|
142
|
+
* Declarative result-click collection for server-rendered markup.
|
|
143
|
+
*
|
|
144
|
+
* A single delegated, capture-phase listener on the root resolves the clicked
|
|
145
|
+
* result and emits it in the canonical joinable shape via the tracker's
|
|
146
|
+
* high-level API - the integrator never hand-builds a UBI event. Delegation
|
|
147
|
+
* means results added or re-rendered after `start()` are covered with no
|
|
148
|
+
* rebinding and no leaked listeners; capture phase means a descendant calling
|
|
149
|
+
* `stopPropagation()` during bubbling cannot lose the click.
|
|
150
|
+
*/
|
|
151
|
+
var ResultClickCollector = class {
|
|
152
|
+
constructor(options, emit) {
|
|
153
|
+
this.hasWarnedUnresolvable = false;
|
|
154
|
+
this.hasWarnedBodyScope = false;
|
|
155
|
+
this.options = options;
|
|
156
|
+
this.emit = emit;
|
|
157
|
+
}
|
|
158
|
+
attach(dispatcher) {
|
|
159
|
+
var _this$options$root, _this$options$resolve;
|
|
160
|
+
const { selector, ignore } = this.options;
|
|
161
|
+
const root = (_this$options$root = this.options.root) !== null && _this$options$root !== void 0 ? _this$options$root : document;
|
|
162
|
+
const resolve = (_this$options$resolve = this.options.resolve) !== null && _this$options$resolve !== void 0 ? _this$options$resolve : ((el) => readResultData(el, dispatcher.logger));
|
|
163
|
+
const handler = (event) => {
|
|
164
|
+
try {
|
|
165
|
+
var _event$composedPath, _resolved$actionName;
|
|
166
|
+
const path = (_event$composedPath = event.composedPath) === null || _event$composedPath === void 0 ? void 0 : _event$composedPath.call(event)[0];
|
|
167
|
+
const target = path instanceof Element ? path : event.target;
|
|
168
|
+
const matched = target === null || target === void 0 ? void 0 : target.closest(selector);
|
|
169
|
+
if (!matched) return;
|
|
170
|
+
if (!this.options.resolve && (matched === document.body || matched === document.documentElement)) {
|
|
171
|
+
if (!this.hasWarnedBodyScope) {
|
|
172
|
+
var _dispatcher$logger;
|
|
173
|
+
this.hasWarnedBodyScope = true;
|
|
174
|
+
(_dispatcher$logger = dispatcher.logger) === null || _dispatcher$logger === void 0 || _dispatcher$logger.warn(`trackResultClicks: the selector "${selector}" matched the page body itself; these clicks are skipped. Narrow the selector to your result elements.`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (ignore) {
|
|
179
|
+
const ignored = target === null || target === void 0 ? void 0 : target.closest(ignore);
|
|
180
|
+
if (ignored && ignored !== matched && matched.contains(ignored)) return;
|
|
181
|
+
}
|
|
182
|
+
const resolved = resolve(matched, event);
|
|
183
|
+
if (!resolved) return;
|
|
184
|
+
const actionName = (_resolved$actionName = resolved.actionName) !== null && _resolved$actionName !== void 0 ? _resolved$actionName : "click";
|
|
185
|
+
const unresolvableClick = actionName === "click" && (!isValidOrdinal(resolved.ordinal) || !resolved.queryId);
|
|
186
|
+
if (!resolved.objectId || unresolvableClick) {
|
|
187
|
+
if (!this.hasWarnedUnresolvable) {
|
|
188
|
+
var _dispatcher$logger2;
|
|
189
|
+
this.hasWarnedUnresolvable = true;
|
|
190
|
+
(_dispatcher$logger2 = dispatcher.logger) === null || _dispatcher$logger2 === void 0 || _dispatcher$logger2.warn(`trackResultClicks: skipped a click matching "${selector}" because objectId, ordinal or queryId could not be resolved. Add data-object-id and data-ordinal to the result element and data-query-id to an ancestor, or supply a resolve callback. Further unresolvable clicks are skipped silently.`);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.emit({
|
|
195
|
+
...resolved,
|
|
196
|
+
actionName
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
var _dispatcher$logger3;
|
|
200
|
+
(_dispatcher$logger3 = dispatcher.logger) === null || _dispatcher$logger3 === void 0 || _dispatcher$logger3.error("Error during handler execution: ", e);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
root.addEventListener("click", handler, { capture: true });
|
|
204
|
+
return () => {
|
|
205
|
+
root.removeEventListener("click", handler, { capture: true });
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/collectors/ResultImpressionCollector.ts
|
|
211
|
+
const FLUSH_DEBOUNCE_MS = 250;
|
|
212
|
+
/**
|
|
213
|
+
* Declarative result-impression collection for server-rendered markup.
|
|
214
|
+
*
|
|
215
|
+
* An `IntersectionObserver` (threshold 0) reports the first time each matching
|
|
216
|
+
* element enters the viewport - once per element, so a card scrolled out and
|
|
217
|
+
* back does not re-fire - and a `MutationObserver` on the root discovers
|
|
218
|
+
* elements added or re-rendered after `start()`, so a results grid re-rendered
|
|
219
|
+
* for a new query is picked up automatically. Resolved impressions are
|
|
220
|
+
* coalesced in memory for a beat and emitted per query in the canonical
|
|
221
|
+
* joinable shape via the tracker's high-level API. Nothing is persisted:
|
|
222
|
+
* impressions pending at detach are dropped deliberately, because after the
|
|
223
|
+
* page moves on their query context can no longer be trusted.
|
|
224
|
+
*/
|
|
225
|
+
var ResultImpressionCollector = class {
|
|
226
|
+
constructor(options, emit) {
|
|
227
|
+
this.hasWarnedUnresolvable = false;
|
|
228
|
+
this.hasWarnedBodyScope = false;
|
|
229
|
+
this.options = options;
|
|
230
|
+
this.emit = emit;
|
|
231
|
+
}
|
|
232
|
+
attach(dispatcher) {
|
|
233
|
+
var _this$options$root, _this$options$resolve;
|
|
234
|
+
const { selector } = this.options;
|
|
235
|
+
const root = (_this$options$root = this.options.root) !== null && _this$options$root !== void 0 ? _this$options$root : document;
|
|
236
|
+
const resolve = (_this$options$resolve = this.options.resolve) !== null && _this$options$resolve !== void 0 ? _this$options$resolve : ((el) => readResultData(el, dispatcher.logger));
|
|
237
|
+
const pending = [];
|
|
238
|
+
const flush = debounce(() => {
|
|
239
|
+
const groups = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const entry of pending) {
|
|
241
|
+
var _entry$query;
|
|
242
|
+
const key = JSON.stringify([entry.queryId, (_entry$query = entry.query) !== null && _entry$query !== void 0 ? _entry$query : null]);
|
|
243
|
+
let group = groups.get(key);
|
|
244
|
+
if (!group) {
|
|
245
|
+
group = {
|
|
246
|
+
queryId: entry.queryId,
|
|
247
|
+
query: entry.query,
|
|
248
|
+
items: []
|
|
249
|
+
};
|
|
250
|
+
groups.set(key, group);
|
|
251
|
+
}
|
|
252
|
+
group.items.push(entry.item);
|
|
253
|
+
}
|
|
254
|
+
pending.length = 0;
|
|
255
|
+
for (const group of groups.values()) try {
|
|
256
|
+
this.emit(group.items, group.queryId, group.query);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
var _dispatcher$logger;
|
|
259
|
+
(_dispatcher$logger = dispatcher.logger) === null || _dispatcher$logger === void 0 || _dispatcher$logger.error("Error emitting impressions: ", e);
|
|
260
|
+
}
|
|
261
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
262
|
+
const observedElements = /* @__PURE__ */ new Set();
|
|
263
|
+
const observer = new IntersectionObserver((entries) => {
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (!entry.isIntersecting) continue;
|
|
266
|
+
const element = entry.target;
|
|
267
|
+
observer.unobserve(element);
|
|
268
|
+
observedElements.delete(element);
|
|
269
|
+
try {
|
|
270
|
+
const resolved = resolve(element);
|
|
271
|
+
if (!resolved) continue;
|
|
272
|
+
if (!resolved.objectId || !isValidOrdinal(resolved.ordinal) || !resolved.queryId) {
|
|
273
|
+
if (!this.hasWarnedUnresolvable) {
|
|
274
|
+
var _dispatcher$logger2;
|
|
275
|
+
this.hasWarnedUnresolvable = true;
|
|
276
|
+
(_dispatcher$logger2 = dispatcher.logger) === null || _dispatcher$logger2 === void 0 || _dispatcher$logger2.warn(`trackResultImpressions: skipped an impression matching "${selector}" because objectId, ordinal or queryId could not be resolved. Add data-object-id and data-ordinal to the result element and data-query-id to an ancestor, or supply a resolve callback. Further unresolvable impressions are skipped silently.`);
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const { actionName: _actionName, queryId: _queryId, query: _query, ...itemData } = resolved;
|
|
281
|
+
const item = {
|
|
282
|
+
...itemData,
|
|
283
|
+
objectId: resolved.objectId,
|
|
284
|
+
ordinal: resolved.ordinal
|
|
285
|
+
};
|
|
286
|
+
if (resolved.objectIdField) item.objectIdField = resolved.objectIdField;
|
|
287
|
+
pending.push({
|
|
288
|
+
item,
|
|
289
|
+
queryId: resolved.queryId,
|
|
290
|
+
query: resolved.query
|
|
291
|
+
});
|
|
292
|
+
flush();
|
|
293
|
+
} catch (e) {
|
|
294
|
+
var _dispatcher$logger3;
|
|
295
|
+
(_dispatcher$logger3 = dispatcher.logger) === null || _dispatcher$logger3 === void 0 || _dispatcher$logger3.error("Error resolving impression: ", e);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}, { threshold: 0 });
|
|
299
|
+
const addHandler = (element) => {
|
|
300
|
+
if (!this.options.resolve && (element === document.body || element === document.documentElement)) {
|
|
301
|
+
if (!this.hasWarnedBodyScope) {
|
|
302
|
+
var _dispatcher$logger4;
|
|
303
|
+
this.hasWarnedBodyScope = true;
|
|
304
|
+
(_dispatcher$logger4 = dispatcher.logger) === null || _dispatcher$logger4 === void 0 || _dispatcher$logger4.warn(`trackResultImpressions: the selector "${selector}" matched the page body itself; it is not observed. Narrow the selector to your result elements.`);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!observedElements.has(element)) {
|
|
309
|
+
observedElements.add(element);
|
|
310
|
+
observer.observe(element);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const removeHandler = (element) => {
|
|
314
|
+
if (observedElements.has(element)) {
|
|
315
|
+
observer.unobserve(element);
|
|
316
|
+
observedElements.delete(element);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
root.querySelectorAll(selector).forEach(addHandler);
|
|
320
|
+
const mutationObserver = createMutationObserver(selector, addHandler, removeHandler);
|
|
321
|
+
mutationObserver.observe(root, {
|
|
322
|
+
childList: true,
|
|
323
|
+
subtree: true
|
|
324
|
+
});
|
|
325
|
+
return () => {
|
|
326
|
+
mutationObserver.disconnect();
|
|
327
|
+
flush.clear();
|
|
328
|
+
observer.disconnect();
|
|
329
|
+
observedElements.clear();
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/logging/ConsoleLogger.ts
|
|
335
|
+
/**
|
|
336
|
+
* The default {@link Logger}. Routes tracker diagnostics to the browser console
|
|
337
|
+
* so that an integrator can see, without any extra wiring, why events are not
|
|
338
|
+
* arriving (bad site_id, non-retryable server response, storage failures, ...).
|
|
339
|
+
* `debug`/`info` are suppressed unless `verbose` is set.
|
|
340
|
+
*/
|
|
341
|
+
var ConsoleLogger = class {
|
|
342
|
+
constructor(options = {}) {
|
|
343
|
+
var _options$verbose;
|
|
344
|
+
this.verbose = (_options$verbose = options.verbose) !== null && _options$verbose !== void 0 ? _options$verbose : false;
|
|
345
|
+
}
|
|
346
|
+
debug(msg, ...data) {
|
|
347
|
+
if (this.verbose) console.debug(msg, ...data);
|
|
348
|
+
}
|
|
349
|
+
info(msg, ...data) {
|
|
350
|
+
if (this.verbose) console.info(msg, ...data);
|
|
351
|
+
}
|
|
352
|
+
warn(msg, ...data) {
|
|
353
|
+
console.warn(msg, ...data);
|
|
354
|
+
}
|
|
355
|
+
error(msg, ...data) {
|
|
356
|
+
console.error(msg, ...data);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/logging/safeLogger.ts
|
|
361
|
+
const report = (e) => {
|
|
362
|
+
try {
|
|
363
|
+
console.error("The configured tracker logger threw: ", e);
|
|
364
|
+
} catch {}
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* Wraps a logger so a throwing consumer logger can never escape the tracker's
|
|
368
|
+
* own catch blocks (which themselves log) or `dispatch()`. The original
|
|
369
|
+
* failure is surfaced via `console.error` rather than swallowed. Internal.
|
|
370
|
+
*/
|
|
371
|
+
const safeLogger = (logger) => ({
|
|
372
|
+
debug(msg, ...data) {
|
|
373
|
+
try {
|
|
374
|
+
logger.debug(msg, ...data);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
report(e);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
info(msg, ...data) {
|
|
380
|
+
try {
|
|
381
|
+
logger.info(msg, ...data);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
report(e);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
warn(msg, ...data) {
|
|
387
|
+
try {
|
|
388
|
+
logger.warn(msg, ...data);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
report(e);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
error(msg, ...data) {
|
|
394
|
+
try {
|
|
395
|
+
logger.error(msg, ...data);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
report(e);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/sinks/AggregateSink.ts
|
|
403
|
+
/**
|
|
404
|
+
* Emits events to multiple sinks
|
|
405
|
+
*/
|
|
406
|
+
var AggregateSink = class {
|
|
407
|
+
constructor(sinks, logger) {
|
|
408
|
+
this.sinks = sinks;
|
|
409
|
+
this.logger = logger;
|
|
410
|
+
}
|
|
411
|
+
add(sink) {
|
|
412
|
+
this.sinks.add(sink);
|
|
413
|
+
}
|
|
414
|
+
delete(sink) {
|
|
415
|
+
this.sinks.delete(sink);
|
|
416
|
+
}
|
|
417
|
+
emit(data) {
|
|
418
|
+
this.sinks.forEach((sink) => {
|
|
419
|
+
try {
|
|
420
|
+
sink.emit(data);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
var _this$logger;
|
|
423
|
+
(_this$logger = this.logger) === null || _this$logger === void 0 || _this$logger.error("Error emitting event to sink", {
|
|
424
|
+
error,
|
|
425
|
+
sink,
|
|
426
|
+
data
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/sinks/BatchSink.ts
|
|
434
|
+
const STORAGE_KEY$1 = "_ubi_batch_retry_";
|
|
435
|
+
const TRACK_EVENT_PATH = "/api/v1/ubi/track-event";
|
|
436
|
+
const MAX_PERSISTED_BATCHES = 10;
|
|
437
|
+
const MAX_PERSISTED_AGE_MS = 864e5;
|
|
438
|
+
/**
|
|
439
|
+
* A sink that batches events, retries failures with exponential backoff,
|
|
440
|
+
* and uses the Beacon API for reliable delivery on page unload.
|
|
441
|
+
*/
|
|
442
|
+
var BatchSink = class {
|
|
443
|
+
constructor(options) {
|
|
444
|
+
this.queue = [];
|
|
445
|
+
this.flushTimer = null;
|
|
446
|
+
this.pendingRetries = /* @__PURE__ */ new Map();
|
|
447
|
+
this.disposed = false;
|
|
448
|
+
const { endpointHost, flushSize = 30, flushIntervalMs = 1e3, maxRetries = 5, retryBaseDelayMs = 1e3, retryMaxDelayMs = 3e4, storage, logger } = options;
|
|
449
|
+
this.url = `${endpointHost.replace(/\/+$/, "")}${TRACK_EVENT_PATH}`;
|
|
450
|
+
this.storageKey = `${STORAGE_KEY$1}${endpointHost.replace(/[^a-zA-Z0-9]+/g, "-")}_`;
|
|
451
|
+
this.flushSize = flushSize;
|
|
452
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
453
|
+
this.maxRetries = maxRetries;
|
|
454
|
+
this.retryBaseDelayMs = retryBaseDelayMs;
|
|
455
|
+
this.retryMaxDelayMs = retryMaxDelayMs;
|
|
456
|
+
this.storage = storage;
|
|
457
|
+
this.logger = logger;
|
|
458
|
+
this.boundOnVisibilityChange = this.onVisibilityChange.bind(this);
|
|
459
|
+
this.boundOnPageHide = this.onPageHide.bind(this);
|
|
460
|
+
this.start();
|
|
461
|
+
this.drainStoredRetries();
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Add an event to the batch queue. Triggers a flush if the queue reaches flushSize.
|
|
465
|
+
*/
|
|
466
|
+
emit(event) {
|
|
467
|
+
if (this.disposed) return;
|
|
468
|
+
this.queue.push(event);
|
|
469
|
+
if (this.queue.length >= this.flushSize) this.flush();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Flush the current queue immediately via fetch.
|
|
473
|
+
* Returns a promise that resolves when the batch is sent (or scheduled for retry).
|
|
474
|
+
*/
|
|
475
|
+
flush() {
|
|
476
|
+
if (this.queue.length === 0) return Promise.resolve();
|
|
477
|
+
const batch = this.queue;
|
|
478
|
+
this.queue = [];
|
|
479
|
+
return this.sendBatch(batch, 0);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Start the flush timer and unload listeners.
|
|
483
|
+
*/
|
|
484
|
+
start() {
|
|
485
|
+
if (this.flushTimer) return;
|
|
486
|
+
this.flushTimer = setInterval(() => {
|
|
487
|
+
this.flush();
|
|
488
|
+
}, this.flushIntervalMs);
|
|
489
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", this.boundOnVisibilityChange);
|
|
490
|
+
if (typeof window !== "undefined") window.addEventListener("pagehide", this.boundOnPageHide);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Stop the flush timer, flush remaining events, and clean up.
|
|
494
|
+
*/
|
|
495
|
+
dispose() {
|
|
496
|
+
this.disposed = true;
|
|
497
|
+
if (this.flushTimer) {
|
|
498
|
+
clearInterval(this.flushTimer);
|
|
499
|
+
this.flushTimer = null;
|
|
500
|
+
}
|
|
501
|
+
this.persistPendingRetries();
|
|
502
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.boundOnVisibilityChange);
|
|
503
|
+
if (typeof window !== "undefined") window.removeEventListener("pagehide", this.boundOnPageHide);
|
|
504
|
+
if (this.queue.length > 0) {
|
|
505
|
+
this.sendViaBeacon(this.queue);
|
|
506
|
+
this.queue = [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Visible for testing - returns the current queue length */
|
|
510
|
+
get pendingCount() {
|
|
511
|
+
return this.queue.length;
|
|
512
|
+
}
|
|
513
|
+
onVisibilityChange() {
|
|
514
|
+
if (typeof document === "undefined") return;
|
|
515
|
+
if (document.visibilityState === "hidden") {
|
|
516
|
+
this.flushViaBeacon();
|
|
517
|
+
this.persistPendingRetries();
|
|
518
|
+
} else if (document.visibilityState === "visible") this.drainStoredRetries();
|
|
519
|
+
}
|
|
520
|
+
onPageHide(event) {
|
|
521
|
+
this.flushViaBeacon();
|
|
522
|
+
if (!event.persisted) this.persistPendingRetries();
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Clear and persist any in-flight retry batches so a page discard or unload
|
|
526
|
+
* does not lose them. Empties the map, so it is safe to call more than once.
|
|
527
|
+
*/
|
|
528
|
+
persistPendingRetries() {
|
|
529
|
+
for (const [timer, batch] of this.pendingRetries) {
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
this.persistForRetry(batch.events, batch.attempt);
|
|
532
|
+
}
|
|
533
|
+
this.pendingRetries.clear();
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Flush all queued events using the Beacon API (reliable on page unload).
|
|
537
|
+
*/
|
|
538
|
+
flushViaBeacon() {
|
|
539
|
+
if (this.queue.length === 0) return;
|
|
540
|
+
const batch = this.queue;
|
|
541
|
+
this.queue = [];
|
|
542
|
+
this.sendViaBeacon(batch);
|
|
543
|
+
}
|
|
544
|
+
sendViaBeacon(events) {
|
|
545
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") try {
|
|
546
|
+
const blob = new Blob([JSON.stringify({ events })], { type: "application/json" });
|
|
547
|
+
if (navigator.sendBeacon(this.url, blob)) {
|
|
548
|
+
var _this$logger;
|
|
549
|
+
(_this$logger = this.logger) === null || _this$logger === void 0 || _this$logger.info(`ubi: queued ${events.length} event(s) via beacon to ${this.url}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
} catch (e) {
|
|
553
|
+
var _this$logger2;
|
|
554
|
+
(_this$logger2 = this.logger) === null || _this$logger2 === void 0 || _this$logger2.error("Beacon API failed: ", e);
|
|
555
|
+
}
|
|
556
|
+
this.sendViaKeepaliveFetch(events);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Fallback delivery on unload when the beacon is unavailable or refused.
|
|
560
|
+
* Persists the batch for the next page load if the fetch fails.
|
|
561
|
+
*/
|
|
562
|
+
sendViaKeepaliveFetch(events) {
|
|
563
|
+
try {
|
|
564
|
+
fetch(this.url, {
|
|
565
|
+
method: "POST",
|
|
566
|
+
headers: { "Content-Type": "application/json" },
|
|
567
|
+
body: JSON.stringify({ events }),
|
|
568
|
+
keepalive: true
|
|
569
|
+
}).then((response) => {
|
|
570
|
+
if (!response.ok) this.persistForRetry(events, 0);
|
|
571
|
+
}).catch(() => {
|
|
572
|
+
this.persistForRetry(events, 0);
|
|
573
|
+
});
|
|
574
|
+
} catch (e) {
|
|
575
|
+
var _this$logger3;
|
|
576
|
+
(_this$logger3 = this.logger) === null || _this$logger3 === void 0 || _this$logger3.error("Keepalive fetch failed: ", e);
|
|
577
|
+
this.persistForRetry(events, 0);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async sendBatch(events, attempt) {
|
|
581
|
+
try {
|
|
582
|
+
const response = await fetch(this.url, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { "Content-Type": "application/json" },
|
|
585
|
+
body: JSON.stringify({ events })
|
|
586
|
+
});
|
|
587
|
+
if (!response.ok) {
|
|
588
|
+
if (this.isRetryable(response.status)) this.scheduleRetry(events, attempt);
|
|
589
|
+
else {
|
|
590
|
+
var _this$logger4;
|
|
591
|
+
(_this$logger4 = this.logger) === null || _this$logger4 === void 0 || _this$logger4.error(`Batch send failed with status ${response.status}, not retrying`);
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
var _this$logger5;
|
|
595
|
+
(_this$logger5 = this.logger) === null || _this$logger5 === void 0 || _this$logger5.info(`ubi: sent ${events.length} event(s) to ${this.url} (${response.status})`);
|
|
596
|
+
}
|
|
597
|
+
} catch (_e) {
|
|
598
|
+
this.scheduleRetry(events, attempt);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
isRetryable(status) {
|
|
602
|
+
return status >= 500 || status === 429;
|
|
603
|
+
}
|
|
604
|
+
scheduleRetry(events, attempt) {
|
|
605
|
+
var _this$logger7;
|
|
606
|
+
if (this.disposed) {
|
|
607
|
+
this.persistForRetry(events, attempt);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (attempt >= this.maxRetries) {
|
|
611
|
+
var _this$logger6;
|
|
612
|
+
(_this$logger6 = this.logger) === null || _this$logger6 === void 0 || _this$logger6.error(`Batch send failed after ${this.maxRetries} attempts, discarding ${events.length} events`);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const delay = this.calculateBackoff(attempt);
|
|
616
|
+
(_this$logger7 = this.logger) === null || _this$logger7 === void 0 || _this$logger7.warn(`Retrying batch (attempt ${attempt + 1}/${this.maxRetries}) in ${delay}ms`);
|
|
617
|
+
const timer = setTimeout(() => {
|
|
618
|
+
this.pendingRetries.delete(timer);
|
|
619
|
+
this.sendBatch(events, attempt + 1);
|
|
620
|
+
}, delay);
|
|
621
|
+
this.pendingRetries.set(timer, {
|
|
622
|
+
events,
|
|
623
|
+
attempt
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
calculateBackoff(attempt) {
|
|
627
|
+
const exponentialDelay = this.retryBaseDelayMs * 2 ** attempt;
|
|
628
|
+
const cappedDelay = Math.min(exponentialDelay, this.retryMaxDelayMs);
|
|
629
|
+
return Math.random() * cappedDelay;
|
|
630
|
+
}
|
|
631
|
+
persistForRetry(events, attempt) {
|
|
632
|
+
if (!this.storage) return;
|
|
633
|
+
try {
|
|
634
|
+
const stored = this.loadStoredRetries();
|
|
635
|
+
stored.push({
|
|
636
|
+
events,
|
|
637
|
+
attempt,
|
|
638
|
+
ts: Date.now()
|
|
639
|
+
});
|
|
640
|
+
const bounded = stored.slice(-10);
|
|
641
|
+
if (bounded.length < stored.length) {
|
|
642
|
+
var _this$logger8;
|
|
643
|
+
(_this$logger8 = this.logger) === null || _this$logger8 === void 0 || _this$logger8.warn(`ubi: dropped ${stored.length - bounded.length} oldest persisted retry batch(es) (cap ${MAX_PERSISTED_BATCHES})`);
|
|
644
|
+
}
|
|
645
|
+
this.storage.setItem(this.storageKey, JSON.stringify(bounded));
|
|
646
|
+
} catch (e) {
|
|
647
|
+
var _this$logger9;
|
|
648
|
+
(_this$logger9 = this.logger) === null || _this$logger9 === void 0 || _this$logger9.error("Failed to persist retry queue: ", e);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
loadStoredRetries() {
|
|
652
|
+
if (!this.storage) return [];
|
|
653
|
+
try {
|
|
654
|
+
const data = this.storage.getItem(this.storageKey);
|
|
655
|
+
const parsed = data ? JSON.parse(data) : [];
|
|
656
|
+
if (!Array.isArray(parsed)) return [];
|
|
657
|
+
return parsed.filter((batch) => Array.isArray(batch === null || batch === void 0 ? void 0 : batch.events) && typeof (batch === null || batch === void 0 ? void 0 : batch.ts) === "number" && Date.now() - batch.ts <= MAX_PERSISTED_AGE_MS);
|
|
658
|
+
} catch {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
drainStoredRetries() {
|
|
663
|
+
var _this$logger10;
|
|
664
|
+
if (!this.storage) return;
|
|
665
|
+
const batches = this.loadStoredRetries();
|
|
666
|
+
if (batches.length === 0) return;
|
|
667
|
+
this.storage.removeItem(this.storageKey);
|
|
668
|
+
(_this$logger10 = this.logger) === null || _this$logger10 === void 0 || _this$logger10.info(`ubi: resuming ${batches.length} persisted retry batch(es)`);
|
|
669
|
+
for (const batch of batches) this.sendBatch(batch.events, batch.attempt);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/sinks/ConsoleSink.ts
|
|
674
|
+
/**
|
|
675
|
+
* Logs every event to the browser console. The development default: used
|
|
676
|
+
* automatically when no `endpointHost` is configured and no sink has been
|
|
677
|
+
* added, so events are visible without anything leaving the page. With an
|
|
678
|
+
* endpoint configured, delivery goes through `BatchSink` instead.
|
|
679
|
+
*/
|
|
680
|
+
var ConsoleSink = class {
|
|
681
|
+
emit(event) {
|
|
682
|
+
console.log(event);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/attribution/AttributionStore.ts
|
|
687
|
+
const STORAGE_KEY = "_ubi_attribution_";
|
|
688
|
+
const MAX_AGE_MS = 864e5;
|
|
689
|
+
const MAX_ENTRIES = 50;
|
|
690
|
+
/**
|
|
691
|
+
* Object-keyed attribution store: `objectId -> { queryId, ordinal, query }`.
|
|
692
|
+
*
|
|
693
|
+
* Written by `Tracker.trackResultClick` and read by `Tracker.trackResultEvent`
|
|
694
|
+
* (and `getResultAttribution`), so a conversion on a later page - including
|
|
695
|
+
* after a full document navigation - resolves the query that produced the
|
|
696
|
+
* clicked result without the integrator threading `query_id` by hand.
|
|
697
|
+
*
|
|
698
|
+
* Records go to BOTH sessionStorage and localStorage: sessionStorage covers
|
|
699
|
+
* same-tab navigation, and the localStorage copy covers a result opened in a
|
|
700
|
+
* new tab (which does not inherit sessionStorage). Resolution is scoped to the
|
|
701
|
+
* session that registered the record, so the shared localStorage copy can
|
|
702
|
+
* never re-attribute a later visit in a different session.
|
|
703
|
+
*
|
|
704
|
+
* The store performs no storage IO at construction; it reads on `get` and
|
|
705
|
+
* writes on `register` only.
|
|
706
|
+
*/
|
|
707
|
+
var AttributionStore = class {
|
|
708
|
+
constructor(options) {
|
|
709
|
+
this.options = options;
|
|
710
|
+
}
|
|
711
|
+
/** Records the attribution for a clicked result. */
|
|
712
|
+
register(objectId, record) {
|
|
713
|
+
const stored = {
|
|
714
|
+
queryId: record.queryId,
|
|
715
|
+
ordinal: record.ordinal,
|
|
716
|
+
query: record.query,
|
|
717
|
+
sessionId: this.options.sessionId(),
|
|
718
|
+
ts: Date.now()
|
|
719
|
+
};
|
|
720
|
+
for (const storage of [this.options.sessionStorage, this.options.localStorage]) {
|
|
721
|
+
const map = this.pruneStale(this.load(storage));
|
|
722
|
+
if (!(objectId in map)) this.enforceCap(map);
|
|
723
|
+
map[objectId] = stored;
|
|
724
|
+
this.save(storage, map);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Returns the attribution recorded for an object in the current session, or
|
|
729
|
+
* undefined. Stale entries (other session, over age) are pruned on read.
|
|
730
|
+
*/
|
|
731
|
+
get(objectId) {
|
|
732
|
+
for (const storage of [this.options.sessionStorage, this.options.localStorage]) {
|
|
733
|
+
const map = this.load(storage);
|
|
734
|
+
const stored = map[objectId];
|
|
735
|
+
if (!stored) continue;
|
|
736
|
+
if (this.isStale(stored)) {
|
|
737
|
+
delete map[objectId];
|
|
738
|
+
this.save(storage, map);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const record = { queryId: stored.queryId };
|
|
742
|
+
if (stored.ordinal !== void 0) record.ordinal = stored.ordinal;
|
|
743
|
+
if (stored.query !== void 0) record.query = stored.query;
|
|
744
|
+
return record;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
isStale(stored) {
|
|
748
|
+
return typeof stored.queryId !== "string" || stored.sessionId !== this.options.sessionId() || Date.now() - stored.ts > MAX_AGE_MS;
|
|
749
|
+
}
|
|
750
|
+
/** Drops stale entries (other session, over age, malformed). */
|
|
751
|
+
pruneStale(map) {
|
|
752
|
+
for (const key of Object.keys(map)) {
|
|
753
|
+
var _map$key;
|
|
754
|
+
if (typeof ((_map$key = map[key]) === null || _map$key === void 0 ? void 0 : _map$key.ts) !== "number" || this.isStale(map[key])) delete map[key];
|
|
755
|
+
}
|
|
756
|
+
return map;
|
|
757
|
+
}
|
|
758
|
+
/** Makes room for one new entry, evicting the oldest beyond the cap. */
|
|
759
|
+
enforceCap(map) {
|
|
760
|
+
const keys = Object.keys(map);
|
|
761
|
+
if (keys.length >= MAX_ENTRIES) keys.sort((a, b) => map[a].ts - map[b].ts).slice(0, keys.length - MAX_ENTRIES + 1).forEach((key) => {
|
|
762
|
+
delete map[key];
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
load(storage) {
|
|
766
|
+
try {
|
|
767
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
768
|
+
if (!raw) return {};
|
|
769
|
+
const parsed = JSON.parse(raw);
|
|
770
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
771
|
+
storage.removeItem(STORAGE_KEY);
|
|
772
|
+
return {};
|
|
773
|
+
}
|
|
774
|
+
return parsed;
|
|
775
|
+
} catch (e) {
|
|
776
|
+
this.logError("Error reading the attribution store: ", e);
|
|
777
|
+
try {
|
|
778
|
+
storage.removeItem(STORAGE_KEY);
|
|
779
|
+
} catch {}
|
|
780
|
+
return {};
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
save(storage, map) {
|
|
784
|
+
try {
|
|
785
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(map));
|
|
786
|
+
} catch (e) {
|
|
787
|
+
this.logError("Error writing the attribution store: ", e);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
logError(message, e) {
|
|
791
|
+
var _this$options$logger, _this$options;
|
|
792
|
+
(_this$options$logger = (_this$options = this.options).logger) === null || _this$options$logger === void 0 || (_this$options$logger = _this$options$logger.call(_this$options)) === null || _this$options$logger === void 0 || _this$options$logger.error(message, e);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/dispatcher.ts
|
|
797
|
+
const TRACKER_VERSION = "1.0.0-bootstrap.0";
|
|
798
|
+
/**
|
|
799
|
+
* Enriches events and dispatches to the sink
|
|
800
|
+
*/
|
|
801
|
+
var DefaultDispatcher = class {
|
|
802
|
+
constructor(enrichers, sink, logger) {
|
|
803
|
+
this.logger = logger;
|
|
804
|
+
this.sink = sink;
|
|
805
|
+
this.enrichers = enrichers;
|
|
806
|
+
}
|
|
807
|
+
dispatch(event) {
|
|
808
|
+
var _this$logger2;
|
|
809
|
+
if (!event.timestamp) event.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
810
|
+
if (!event.event_attributes) event.event_attributes = {};
|
|
811
|
+
if (!event.event_attributes.event_id) event.event_attributes.event_id = (0, ulidx.ulid)();
|
|
812
|
+
if (!event.event_attributes.tracker) event.event_attributes.tracker = { version: TRACKER_VERSION };
|
|
813
|
+
this.enrichers.forEach((enricher) => {
|
|
814
|
+
try {
|
|
815
|
+
enricher.enrich(event);
|
|
816
|
+
} catch (e) {
|
|
817
|
+
var _this$logger;
|
|
818
|
+
(_this$logger = this.logger) === null || _this$logger === void 0 || _this$logger.error("Error enriching event: ", e);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
(_this$logger2 = this.logger) === null || _this$logger2 === void 0 || _this$logger2.debug("ubi: dispatching event", event);
|
|
822
|
+
try {
|
|
823
|
+
this.sink.emit(event);
|
|
824
|
+
} catch (e) {
|
|
825
|
+
var _this$logger3;
|
|
826
|
+
(_this$logger3 = this.logger) === null || _this$logger3 === void 0 || _this$logger3.error("Error emitting event to sink: ", e);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/enrichers/BrowserEnricher.ts
|
|
832
|
+
/**
|
|
833
|
+
* Enriches the event with information about the browser:
|
|
834
|
+
* - user_agent
|
|
835
|
+
* - language
|
|
836
|
+
* - screen resolution
|
|
837
|
+
* - webdriver (only when set: the one cheap automation signal, so analysis
|
|
838
|
+
* can filter bot and headless traffic that would distort CTR)
|
|
839
|
+
*/
|
|
840
|
+
var BrowserEnricher = class {
|
|
841
|
+
enrich(event) {
|
|
842
|
+
if (!event.event_attributes) event.event_attributes = {};
|
|
843
|
+
const browser = {
|
|
844
|
+
...event.event_attributes.browser,
|
|
845
|
+
user_agent: navigator.userAgent,
|
|
846
|
+
language: navigator.language,
|
|
847
|
+
resolution: {
|
|
848
|
+
width: screen.width,
|
|
849
|
+
height: screen.height
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
if (navigator.webdriver) browser.webdriver = true;
|
|
853
|
+
event.event_attributes.browser = browser;
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
//#endregion
|
|
857
|
+
//#region src/enrichers/ClientIdEnricher.ts
|
|
858
|
+
/**
|
|
859
|
+
* Enriches the event with
|
|
860
|
+
* - client_id
|
|
861
|
+
*/
|
|
862
|
+
var ClientIdEnricher = class {
|
|
863
|
+
constructor(clientIdProvider) {
|
|
864
|
+
this.clientIdProvider = clientIdProvider;
|
|
865
|
+
}
|
|
866
|
+
enrich(event) {
|
|
867
|
+
if (!event.client_id) event.client_id = this.clientIdProvider();
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/enrichers/OptionsEnricher.ts
|
|
872
|
+
/**
|
|
873
|
+
* Enriches the event with configured values:
|
|
874
|
+
* - application
|
|
875
|
+
* - user_id (late-bound via the getter)
|
|
876
|
+
* - siteId (stamped as `site_id` on the event)
|
|
877
|
+
*/
|
|
878
|
+
var OptionsEnricher = class {
|
|
879
|
+
constructor(values) {
|
|
880
|
+
this.values = values;
|
|
881
|
+
}
|
|
882
|
+
enrich(data) {
|
|
883
|
+
if (!data.application) data.application = this.values.application;
|
|
884
|
+
const userId = this.values.userId();
|
|
885
|
+
if (!data.user_id && userId) data.user_id = userId;
|
|
886
|
+
if (!data.site_id && this.values.siteId) data.site_id = this.values.siteId;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
//#endregion
|
|
890
|
+
//#region src/enrichers/PageEnricher.ts
|
|
891
|
+
/**
|
|
892
|
+
* Enriches the event with information about the page:
|
|
893
|
+
* - url
|
|
894
|
+
* - title
|
|
895
|
+
* - referrer
|
|
896
|
+
*/
|
|
897
|
+
var PageEnricher = class {
|
|
898
|
+
enrich(event) {
|
|
899
|
+
if (!event.event_attributes) event.event_attributes = {};
|
|
900
|
+
event.event_attributes.page = {
|
|
901
|
+
...event.event_attributes.page,
|
|
902
|
+
url: location.href,
|
|
903
|
+
title: document.title,
|
|
904
|
+
referrer: document.referrer || ""
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
//#endregion
|
|
909
|
+
//#region src/enrichers/SessionEnricher.ts
|
|
910
|
+
/**
|
|
911
|
+
* Enriches the event with
|
|
912
|
+
* - session_id
|
|
913
|
+
*
|
|
914
|
+
* Optionally calls onActivity to update the session's last activity timestamp.
|
|
915
|
+
*/
|
|
916
|
+
var SessionEnricher = class {
|
|
917
|
+
constructor(sessionIdProvider, onActivity) {
|
|
918
|
+
this.sessionIdProvider = sessionIdProvider;
|
|
919
|
+
this.onActivity = onActivity;
|
|
920
|
+
}
|
|
921
|
+
enrich(event) {
|
|
922
|
+
var _this$onActivity;
|
|
923
|
+
if (!event.session_id) event.session_id = this.sessionIdProvider();
|
|
924
|
+
(_this$onActivity = this.onActivity) === null || _this$onActivity === void 0 || _this$onActivity.call(this);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/storage/MemoryStorage.ts
|
|
929
|
+
/**
|
|
930
|
+
* An in-memory {@link Storage} implementation used when no real Web Storage is
|
|
931
|
+
* available - most importantly under server-side rendering (no `window`), where
|
|
932
|
+
* touching `localStorage`/`document.cookie` would throw. It provides page-scoped
|
|
933
|
+
* state with no cross-page persistence, so the tracker constructs and runs
|
|
934
|
+
* inertly on the server and starts persisting once it hydrates in the browser.
|
|
935
|
+
*/
|
|
936
|
+
var MemoryStorage = class {
|
|
937
|
+
constructor() {
|
|
938
|
+
this.store = /* @__PURE__ */ new Map();
|
|
939
|
+
}
|
|
940
|
+
get length() {
|
|
941
|
+
return this.store.size;
|
|
942
|
+
}
|
|
943
|
+
clear() {
|
|
944
|
+
this.store.clear();
|
|
945
|
+
}
|
|
946
|
+
getItem(key) {
|
|
947
|
+
var _this$store$get;
|
|
948
|
+
return (_this$store$get = this.store.get(key)) !== null && _this$store$get !== void 0 ? _this$store$get : null;
|
|
949
|
+
}
|
|
950
|
+
key(index) {
|
|
951
|
+
var _Array$from$index;
|
|
952
|
+
return (_Array$from$index = Array.from(this.store.keys())[index]) !== null && _Array$from$index !== void 0 ? _Array$from$index : null;
|
|
953
|
+
}
|
|
954
|
+
removeItem(key) {
|
|
955
|
+
this.store.delete(key);
|
|
956
|
+
}
|
|
957
|
+
setItem(key, value) {
|
|
958
|
+
this.store.set(key, String(value));
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/storage/ClientId.ts
|
|
963
|
+
const CLIENT_ID_KEY = "_ubi_client_id_";
|
|
964
|
+
/**
|
|
965
|
+
* Gets or creates a stable anonymous client ID persisted in localStorage.
|
|
966
|
+
* This ID survives across sessions and is used to identify the device/browser.
|
|
967
|
+
* A failing storage read or write is logged and a freshly generated id is
|
|
968
|
+
* still returned, so tracking continues (per page) without persistence.
|
|
969
|
+
*/
|
|
970
|
+
function getOrCreateClientId(storage, logger) {
|
|
971
|
+
let clientId = null;
|
|
972
|
+
try {
|
|
973
|
+
clientId = storage.getItem(CLIENT_ID_KEY);
|
|
974
|
+
} catch (e) {
|
|
975
|
+
logger === null || logger === void 0 || logger.error("Error reading the client id: ", e);
|
|
976
|
+
}
|
|
977
|
+
if (!clientId) {
|
|
978
|
+
clientId = (0, ulidx.ulid)();
|
|
979
|
+
try {
|
|
980
|
+
storage.setItem(CLIENT_ID_KEY, clientId);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
logger === null || logger === void 0 || logger.error("Error persisting the client id: ", e);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return clientId;
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/storage/SessionManager.ts
|
|
989
|
+
const SESSION_KEY = "_ubi_session_";
|
|
990
|
+
const MAX_CLOCK_SKEW_MS = 3e5;
|
|
991
|
+
const THIRTY_MINUTES = 18e5;
|
|
992
|
+
const TWENTY_FOUR_HOURS = 864e5;
|
|
993
|
+
/**
|
|
994
|
+
* Manages session IDs with inactivity timeout and maximum duration.
|
|
995
|
+
*/
|
|
996
|
+
var SessionManager = class {
|
|
997
|
+
constructor(options) {
|
|
998
|
+
var _options$inactivityTi, _options$maxSessionDu;
|
|
999
|
+
this.storage = options.storage;
|
|
1000
|
+
this.inactivityTimeoutMs = (_options$inactivityTi = options.inactivityTimeoutMs) !== null && _options$inactivityTi !== void 0 ? _options$inactivityTi : THIRTY_MINUTES;
|
|
1001
|
+
this.maxSessionDurationMs = (_options$maxSessionDu = options.maxSessionDurationMs) !== null && _options$maxSessionDu !== void 0 ? _options$maxSessionDu : TWENTY_FOUR_HOURS;
|
|
1002
|
+
this.logger = options.logger;
|
|
1003
|
+
this.session = this.loadOrCreate();
|
|
1004
|
+
}
|
|
1005
|
+
get sessionId() {
|
|
1006
|
+
this.syncFromStorage();
|
|
1007
|
+
if (this.isExpired()) this.rotate();
|
|
1008
|
+
return this.session.id;
|
|
1009
|
+
}
|
|
1010
|
+
syncFromStorage() {
|
|
1011
|
+
try {
|
|
1012
|
+
const stored = this.storage.getItem(SESSION_KEY);
|
|
1013
|
+
if (!stored) return;
|
|
1014
|
+
const data = JSON.parse(stored);
|
|
1015
|
+
if (!this.isUsable(data, Date.now())) return;
|
|
1016
|
+
if (data.id !== this.session.id || data.lastActivity > this.session.lastActivity) this.session = data;
|
|
1017
|
+
} catch {}
|
|
1018
|
+
}
|
|
1019
|
+
/** Update the last activity timestamp. Call on every event. */
|
|
1020
|
+
touch() {
|
|
1021
|
+
this.session.lastActivity = Date.now();
|
|
1022
|
+
this.persist();
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Marks user activity that is not an event: reconciles with storage,
|
|
1026
|
+
* rotates if genuinely expired, then refreshes the activity timestamp.
|
|
1027
|
+
* Called from `Tracker.start()` so that on a classic multi-page site every
|
|
1028
|
+
* page load counts as a hit - the GA-style 30-minute convention - instead
|
|
1029
|
+
* of only dispatched events keeping the session alive. Without it, reading
|
|
1030
|
+
* a product page for 31 minutes rotates the session and drops attribution.
|
|
1031
|
+
*/
|
|
1032
|
+
activity() {
|
|
1033
|
+
this.sessionId;
|
|
1034
|
+
this.touch();
|
|
1035
|
+
}
|
|
1036
|
+
/** True when stored session data is well-formed and temporally sane. */
|
|
1037
|
+
isUsable(data, now) {
|
|
1038
|
+
return typeof data.id === "string" && data.id.length > 0 && Number.isFinite(data.createdAt) && Number.isFinite(data.lastActivity) && data.createdAt <= now + MAX_CLOCK_SKEW_MS && data.lastActivity <= now + MAX_CLOCK_SKEW_MS;
|
|
1039
|
+
}
|
|
1040
|
+
isExpired() {
|
|
1041
|
+
const now = Date.now();
|
|
1042
|
+
const inactivityExpired = now - this.session.lastActivity > this.inactivityTimeoutMs;
|
|
1043
|
+
const maxDurationExpired = now - this.session.createdAt > this.maxSessionDurationMs;
|
|
1044
|
+
return inactivityExpired || maxDurationExpired;
|
|
1045
|
+
}
|
|
1046
|
+
rotate() {
|
|
1047
|
+
this.session = this.createSession();
|
|
1048
|
+
this.persist();
|
|
1049
|
+
}
|
|
1050
|
+
loadOrCreate() {
|
|
1051
|
+
try {
|
|
1052
|
+
const stored = this.storage.getItem(SESSION_KEY);
|
|
1053
|
+
if (stored) {
|
|
1054
|
+
const data = JSON.parse(stored);
|
|
1055
|
+
if (this.isUsable(data, Date.now())) return data;
|
|
1056
|
+
}
|
|
1057
|
+
} catch {}
|
|
1058
|
+
const session = this.createSession();
|
|
1059
|
+
this.persist(session);
|
|
1060
|
+
return session;
|
|
1061
|
+
}
|
|
1062
|
+
createSession() {
|
|
1063
|
+
const now = Date.now();
|
|
1064
|
+
return {
|
|
1065
|
+
id: (0, ulidx.ulid)(),
|
|
1066
|
+
createdAt: now,
|
|
1067
|
+
lastActivity: now
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
persist(session) {
|
|
1071
|
+
try {
|
|
1072
|
+
this.storage.setItem(SESSION_KEY, JSON.stringify(session !== null && session !== void 0 ? session : this.session));
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
var _this$logger;
|
|
1075
|
+
(_this$logger = this.logger) === null || _this$logger === void 0 || _this$logger.error("Error persisting the session: ", e);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/storage/index.ts
|
|
1081
|
+
function tryGetStorage(storageName) {
|
|
1082
|
+
if (typeof window === "undefined") return;
|
|
1083
|
+
try {
|
|
1084
|
+
if (!(storageName in window)) return;
|
|
1085
|
+
const storage = window[storageName];
|
|
1086
|
+
if (!storage) return;
|
|
1087
|
+
const uid = `_ubi_probe_${Date.now()}`;
|
|
1088
|
+
storage.setItem(uid, uid);
|
|
1089
|
+
const result = storage.getItem(uid) === uid;
|
|
1090
|
+
storage.removeItem(uid);
|
|
1091
|
+
return result ? storage : void 0;
|
|
1092
|
+
} catch (_exception) {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const initLocalStorage = () => {
|
|
1097
|
+
var _tryGetStorage;
|
|
1098
|
+
return (_tryGetStorage = tryGetStorage("localStorage")) !== null && _tryGetStorage !== void 0 ? _tryGetStorage : new MemoryStorage();
|
|
1099
|
+
};
|
|
1100
|
+
const initSessionStorage = () => {
|
|
1101
|
+
var _tryGetStorage2;
|
|
1102
|
+
return (_tryGetStorage2 = tryGetStorage("sessionStorage")) !== null && _tryGetStorage2 !== void 0 ? _tryGetStorage2 : new MemoryStorage();
|
|
1103
|
+
};
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/tracker.ts
|
|
1106
|
+
const PRE_START_BUFFER_LIMIT = 100;
|
|
1107
|
+
const IMPRESSION_DEDUP_LIMIT = 1e3;
|
|
1108
|
+
/**
|
|
1109
|
+
* The entry point of `@releval/tracker`: collects User Behavior Insights
|
|
1110
|
+
* events - searches, result impressions, result clicks and the conversions
|
|
1111
|
+
* that follow - in the canonical joinable shape
|
|
1112
|
+
* (`query_id` + `object_id` + `ordinal`) and delivers them to a Releval
|
|
1113
|
+
* deployment's track-event API.
|
|
1114
|
+
*
|
|
1115
|
+
* Events flow through a small pipeline: the high-level `track*` methods (or
|
|
1116
|
+
* the declarative collectors) build canonical events, enrichers stamp
|
|
1117
|
+
* context (application, session, client id, page, browser), and sinks
|
|
1118
|
+
* deliver - batched with retry to the endpoint, or to the console in
|
|
1119
|
+
* development. Nothing is delivered before {@link Tracker.start}; earlier
|
|
1120
|
+
* dispatches are buffered and replayed.
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* ```ts
|
|
1124
|
+
* const tracker = new Tracker({
|
|
1125
|
+
* application: "primary-search",
|
|
1126
|
+
* siteId: "YOUR_SITE_ID",
|
|
1127
|
+
* endpointHost: "https://releval.example.com",
|
|
1128
|
+
* });
|
|
1129
|
+
* tracker.start();
|
|
1130
|
+
*
|
|
1131
|
+
* tracker.trackSearch({ query, queryId }); // ids come from your backend
|
|
1132
|
+
* tracker.trackResultClick({ objectId, ordinal, queryId });
|
|
1133
|
+
* tracker.trackResultEvent({ actionName: "add_to_cart", objectId });
|
|
1134
|
+
* ```
|
|
1135
|
+
*
|
|
1136
|
+
* The full integration flow is documented at
|
|
1137
|
+
* https://releval.co/docs/user-behavior-insights/browser-tracker
|
|
1138
|
+
*/
|
|
1139
|
+
var Tracker = class {
|
|
1140
|
+
constructor(options) {
|
|
1141
|
+
var _options$logger;
|
|
1142
|
+
this.hasEverStarted = false;
|
|
1143
|
+
this.detaches = /* @__PURE__ */ new Map();
|
|
1144
|
+
this.hasWarnedMissingQueryId = false;
|
|
1145
|
+
this.hasWarnedUnattributedResult = false;
|
|
1146
|
+
this.hasWarnedInvalidOrdinal = false;
|
|
1147
|
+
this.preStartBuffer = [];
|
|
1148
|
+
this.hasWarnedPreStartOverflow = false;
|
|
1149
|
+
this.seenImpressions = /* @__PURE__ */ new Set();
|
|
1150
|
+
this.extraSinks = /* @__PURE__ */ new Set();
|
|
1151
|
+
this.options = options;
|
|
1152
|
+
this.collectors = /* @__PURE__ */ new Set();
|
|
1153
|
+
this.started = false;
|
|
1154
|
+
this.logger = safeLogger((_options$logger = options.logger) !== null && _options$logger !== void 0 ? _options$logger : new ConsoleLogger({ verbose: options.debug }));
|
|
1155
|
+
this._sessionManager = new SessionManager({
|
|
1156
|
+
storage: this.localStorage,
|
|
1157
|
+
inactivityTimeoutMs: options.sessionInactivityTimeoutMs,
|
|
1158
|
+
maxSessionDurationMs: options.maxSessionDurationMs,
|
|
1159
|
+
logger: this.logger
|
|
1160
|
+
});
|
|
1161
|
+
this._userId = options.userId;
|
|
1162
|
+
this.enrichers = /* @__PURE__ */ new Set([
|
|
1163
|
+
new OptionsEnricher({
|
|
1164
|
+
application: options.application,
|
|
1165
|
+
siteId: options.siteId,
|
|
1166
|
+
userId: () => this._userId
|
|
1167
|
+
}),
|
|
1168
|
+
new BrowserEnricher(),
|
|
1169
|
+
new PageEnricher(),
|
|
1170
|
+
new SessionEnricher(() => this.sessionId, () => this._sessionManager.touch()),
|
|
1171
|
+
new ClientIdEnricher(() => this.clientId)
|
|
1172
|
+
]);
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* The tracker's local storage (with fallbacks when Web Storage is
|
|
1176
|
+
* unavailable). Internal: collectors and stores receive it via options.
|
|
1177
|
+
*/
|
|
1178
|
+
get localStorage() {
|
|
1179
|
+
if (!this._localStorage) this._localStorage = initLocalStorage();
|
|
1180
|
+
return this._localStorage;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The tracker's session storage (with fallbacks when Web Storage is
|
|
1184
|
+
* unavailable). Internal: collectors and stores receive it via options.
|
|
1185
|
+
*/
|
|
1186
|
+
get sessionStorage() {
|
|
1187
|
+
if (!this._sessionStorage) this._sessionStorage = initSessionStorage();
|
|
1188
|
+
return this._sessionStorage;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Gets the session identifier. Automatically rotates on inactivity or max duration.
|
|
1192
|
+
*/
|
|
1193
|
+
get sessionId() {
|
|
1194
|
+
return this._sessionManager.sessionId;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Gets the stable anonymous client/device ID. Persisted in localStorage across sessions.
|
|
1198
|
+
*/
|
|
1199
|
+
get clientId() {
|
|
1200
|
+
if (!this._clientId) this._clientId = getOrCreateClientId(this.localStorage, this.logger);
|
|
1201
|
+
return this._clientId;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Sets (or clears, with `undefined`) the user id stamped on events - for a
|
|
1205
|
+
* login or logout that happens without a page reload. Applies to events
|
|
1206
|
+
* dispatched after the call; events already queued or persisted for retry
|
|
1207
|
+
* keep the identity they were stamped with. An explicit `user_id` on a
|
|
1208
|
+
* dispatched event still wins. Callable before `start()`. Does not rotate
|
|
1209
|
+
* the session or touch the client id. The value must be an opaque,
|
|
1210
|
+
* pseudonymous identifier - never an email address or name.
|
|
1211
|
+
*/
|
|
1212
|
+
setUserId(userId) {
|
|
1213
|
+
this._userId = userId;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* The object-keyed attribution store, created on first use so construction
|
|
1217
|
+
* performs no storage IO for it.
|
|
1218
|
+
*/
|
|
1219
|
+
get attribution() {
|
|
1220
|
+
if (!this._attribution) this._attribution = new AttributionStore({
|
|
1221
|
+
localStorage: this.localStorage,
|
|
1222
|
+
sessionStorage: this.sessionStorage,
|
|
1223
|
+
sessionId: () => this.sessionId,
|
|
1224
|
+
logger: () => this.logger
|
|
1225
|
+
});
|
|
1226
|
+
return this._attribution;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Returns the attribution recorded by {@link Tracker.trackResultClick} for
|
|
1230
|
+
* an object in the current session, or undefined. Use it to build custom
|
|
1231
|
+
* conversion payloads (e.g. a checkout page resolving several line items).
|
|
1232
|
+
*/
|
|
1233
|
+
getResultAttribution(objectId) {
|
|
1234
|
+
try {
|
|
1235
|
+
return this.attribution.get(objectId);
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
this.logger.error("Error reading result attribution: ", e);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Reports that a search ran, with the server-issued `query_id`. Call this
|
|
1243
|
+
* after your search backend returns results.
|
|
1244
|
+
*
|
|
1245
|
+
* Dispatches a `search` event (`message_type: "QUERY"`) carrying `query_id`
|
|
1246
|
+
* and `user_query`. Without it a query that gets no impression and no click
|
|
1247
|
+
* leaves no event at all, so abandonment and reformulation cannot be
|
|
1248
|
+
* analysed downstream.
|
|
1249
|
+
*/
|
|
1250
|
+
trackSearch(options) {
|
|
1251
|
+
try {
|
|
1252
|
+
const { query, queryId, ...extras } = options;
|
|
1253
|
+
if (!queryId && !this.hasWarnedMissingQueryId) {
|
|
1254
|
+
this.hasWarnedMissingQueryId = true;
|
|
1255
|
+
this.logger.warn("Tracker.trackSearch: called without a queryId. The search event is still emitted but result clicks and impressions cannot be attributed back to this query. Pass the query_id issued by your search backend (Releval track-query): tracker.trackSearch({ query, queryId }).");
|
|
1256
|
+
}
|
|
1257
|
+
const event = {
|
|
1258
|
+
action_name: "search",
|
|
1259
|
+
message_type: "QUERY",
|
|
1260
|
+
user_query: query
|
|
1261
|
+
};
|
|
1262
|
+
if (queryId) event.query_id = queryId;
|
|
1263
|
+
if (Object.keys(extras).length > 0) event.event_attributes = { ...extras };
|
|
1264
|
+
this.dispatch(event);
|
|
1265
|
+
} catch (e) {
|
|
1266
|
+
this.logger.error("Error tracking search: ", e);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Dispatches a UBI event for a search result in the canonical shape
|
|
1271
|
+
* (`query_id`, `event_attributes.object.object_id`, `event_attributes.position.ordinal`),
|
|
1272
|
+
* so callers never hand-build it. Prefer {@link Tracker.trackResultClick} /
|
|
1273
|
+
* {@link Tracker.trackResultImpression}; use this directly for conversions
|
|
1274
|
+
* (e.g. `actionName: "add_to_cart"` or `"purchase"`), where `queryId` and
|
|
1275
|
+
* `ordinal` may be omitted and are resolved from the attribution recorded
|
|
1276
|
+
* when the result was clicked - including on a later page. When `queryId`
|
|
1277
|
+
* IS supplied and matches the recorded click, a missing `ordinal`/`query`
|
|
1278
|
+
* is still borrowed from it, so the row shape does not depend on which
|
|
1279
|
+
* page supplied the id.
|
|
1280
|
+
*/
|
|
1281
|
+
trackResultEvent(options) {
|
|
1282
|
+
try {
|
|
1283
|
+
const { actionName, objectId, objectIdField, ordinal: suppliedOrdinal, queryId: suppliedQueryId, query: suppliedQuery, ...extras } = options;
|
|
1284
|
+
let ordinal = suppliedOrdinal;
|
|
1285
|
+
let queryId = suppliedQueryId;
|
|
1286
|
+
let query = suppliedQuery;
|
|
1287
|
+
if (queryId === void 0 || ordinal === void 0 || query === void 0) {
|
|
1288
|
+
const attribution = this.attribution.get(objectId);
|
|
1289
|
+
if (attribution) {
|
|
1290
|
+
if (queryId === void 0) {
|
|
1291
|
+
var _ordinal, _query;
|
|
1292
|
+
queryId = attribution.queryId;
|
|
1293
|
+
ordinal = (_ordinal = ordinal) !== null && _ordinal !== void 0 ? _ordinal : attribution.ordinal;
|
|
1294
|
+
query = (_query = query) !== null && _query !== void 0 ? _query : attribution.query;
|
|
1295
|
+
} else if (attribution.queryId === queryId) {
|
|
1296
|
+
var _ordinal2, _query2;
|
|
1297
|
+
ordinal = (_ordinal2 = ordinal) !== null && _ordinal2 !== void 0 ? _ordinal2 : attribution.ordinal;
|
|
1298
|
+
query = (_query2 = query) !== null && _query2 !== void 0 ? _query2 : attribution.query;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (ordinal !== void 0 && !isValidOrdinal(ordinal)) {
|
|
1303
|
+
if (!this.hasWarnedInvalidOrdinal) {
|
|
1304
|
+
this.hasWarnedInvalidOrdinal = true;
|
|
1305
|
+
this.logger.warn(`Tracker: ignoring invalid ordinal ${String(ordinal)} for "${objectId}" - an ordinal is a positive integer: (page - 1) * pageSize + positionOnPage.`);
|
|
1306
|
+
}
|
|
1307
|
+
ordinal = void 0;
|
|
1308
|
+
}
|
|
1309
|
+
if (!queryId && !this.hasWarnedUnattributedResult) {
|
|
1310
|
+
this.hasWarnedUnattributedResult = true;
|
|
1311
|
+
this.logger.warn(`Tracker.trackResultEvent: no queryId was supplied and no attribution is recorded for "${objectId}" in this session, so the event is sent unattributed. Either pass queryId explicitly or report the originating click with tracker.trackResultClick so conversions resolve automatically.`);
|
|
1312
|
+
}
|
|
1313
|
+
const object = { object_id: objectId };
|
|
1314
|
+
if (objectIdField) object.object_id_field = objectIdField;
|
|
1315
|
+
const event = {
|
|
1316
|
+
action_name: actionName,
|
|
1317
|
+
event_attributes: {
|
|
1318
|
+
...extras,
|
|
1319
|
+
object
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
if (typeof ordinal === "number") event.event_attributes.position = { ordinal };
|
|
1323
|
+
if (queryId) event.query_id = queryId;
|
|
1324
|
+
if (query) event.user_query = query;
|
|
1325
|
+
this.dispatch(event);
|
|
1326
|
+
} catch (e) {
|
|
1327
|
+
this.logger.error("Error tracking result event: ", e);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Dispatches a `click` event for a clicked search result, attributed to
|
|
1332
|
+
* `queryId` (required at the call site by design), and records the
|
|
1333
|
+
* attribution for the object so later conversion events for it - on this
|
|
1334
|
+
* page or a later one - resolve the originating query automatically.
|
|
1335
|
+
*/
|
|
1336
|
+
trackResultClick(options) {
|
|
1337
|
+
try {
|
|
1338
|
+
var _options$actionName;
|
|
1339
|
+
const { objectId, ordinal, queryId, query } = options;
|
|
1340
|
+
this.trackResultEvent({
|
|
1341
|
+
...options,
|
|
1342
|
+
actionName: (_options$actionName = options.actionName) !== null && _options$actionName !== void 0 ? _options$actionName : "click"
|
|
1343
|
+
});
|
|
1344
|
+
this.attribution.register(objectId, {
|
|
1345
|
+
queryId,
|
|
1346
|
+
ordinal,
|
|
1347
|
+
query
|
|
1348
|
+
});
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
this.logger.error("Error tracking result click: ", e);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Dispatches an `impression` event for each result that became visible,
|
|
1355
|
+
* attributed to `queryId`. Emits one canonical event per item so impressions
|
|
1356
|
+
* join to clicks on `object_id`/`ordinal`.
|
|
1357
|
+
*
|
|
1358
|
+
* Each `(queryId, objectId)` pair is reported ONCE per Tracker instance:
|
|
1359
|
+
* re-renders, virtualized-list remounts and re-discovered elements do not
|
|
1360
|
+
* inflate the impression count (the CTR denominator), while a new `queryId`
|
|
1361
|
+
* re-fires for results returned by consecutive searches. A full page load
|
|
1362
|
+
* builds a fresh Tracker and so starts fresh; when revisits matter,
|
|
1363
|
+
* deduplicate downstream on distinct `(query_id, object_id)`.
|
|
1364
|
+
*/
|
|
1365
|
+
trackResultImpression(options) {
|
|
1366
|
+
try {
|
|
1367
|
+
for (const item of options.items) {
|
|
1368
|
+
const key = `${options.queryId}\u0000${item.objectId}`;
|
|
1369
|
+
if (this.seenImpressions.has(key)) continue;
|
|
1370
|
+
if (this.seenImpressions.size >= IMPRESSION_DEDUP_LIMIT) {
|
|
1371
|
+
const oldest = this.seenImpressions.values().next().value;
|
|
1372
|
+
if (oldest !== void 0) this.seenImpressions.delete(oldest);
|
|
1373
|
+
}
|
|
1374
|
+
this.seenImpressions.add(key);
|
|
1375
|
+
this.trackResultEvent({
|
|
1376
|
+
...item,
|
|
1377
|
+
actionName: "impression",
|
|
1378
|
+
queryId: options.queryId,
|
|
1379
|
+
query: options.query
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
this.logger.error("Error tracking result impression: ", e);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Adds an enricher that runs on every event before emission (e.g. stamping
|
|
1388
|
+
* an A/B variant, store or locale - the split key for any comparison).
|
|
1389
|
+
* Returns a disposer that removes it again.
|
|
1390
|
+
*/
|
|
1391
|
+
addEnricher(enricher) {
|
|
1392
|
+
try {
|
|
1393
|
+
this.enrichers.add(enricher);
|
|
1394
|
+
return () => {
|
|
1395
|
+
this.enrichers.delete(enricher);
|
|
1396
|
+
};
|
|
1397
|
+
} catch (e) {
|
|
1398
|
+
this.logger.error("Error adding enricher: ", e);
|
|
1399
|
+
return () => {};
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Registers a collector so it attaches at start() (or immediately when the
|
|
1404
|
+
* tracker is already started), and returns a function that detaches and
|
|
1405
|
+
* unregisters it again.
|
|
1406
|
+
*/
|
|
1407
|
+
registerCollector(collector) {
|
|
1408
|
+
try {
|
|
1409
|
+
if (!this.collectors.has(collector)) {
|
|
1410
|
+
if (this.started) try {
|
|
1411
|
+
this.detaches.set(collector, collector.attach(this.dispatcher));
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
this.logger.error("Error attaching collector: ", e);
|
|
1414
|
+
}
|
|
1415
|
+
this.collectors.add(collector);
|
|
1416
|
+
}
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
this.logger.error("Error adding collector: ", e);
|
|
1419
|
+
}
|
|
1420
|
+
return () => {
|
|
1421
|
+
try {
|
|
1422
|
+
const detach = this.detaches.get(collector);
|
|
1423
|
+
this.detaches.delete(collector);
|
|
1424
|
+
this.collectors.delete(collector);
|
|
1425
|
+
detach === null || detach === void 0 || detach();
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
this.logger.error("Error detaching collector: ", e);
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Binds declarative result-click collection to the DOM: one delegated
|
|
1433
|
+
* listener reports clicks on elements matching `selector` in the canonical
|
|
1434
|
+
* joinable shape, reading `data-object-id` / `data-ordinal` from the result
|
|
1435
|
+
* element and `data-query-id` from its nearest ancestor (or a custom
|
|
1436
|
+
* `resolve`). A resolved `data-action-name` other than `click` (e.g.
|
|
1437
|
+
* `add_to_cart`) is routed through {@link Tracker.trackResultEvent}, so its
|
|
1438
|
+
* attribution can resolve from the recorded click.
|
|
1439
|
+
*
|
|
1440
|
+
* Use `ignore` for interactive descendants of a result (an add-to-cart
|
|
1441
|
+
* button inside the card) so their clicks are not double-reported as result
|
|
1442
|
+
* clicks.
|
|
1443
|
+
*
|
|
1444
|
+
* @returns a function that stops this collection again.
|
|
1445
|
+
*/
|
|
1446
|
+
trackResultClicks(options) {
|
|
1447
|
+
try {
|
|
1448
|
+
const collector = new ResultClickCollector(options, (resolved) => this.emitResolvedResultClick(resolved));
|
|
1449
|
+
return this.registerCollector(collector);
|
|
1450
|
+
} catch (e) {
|
|
1451
|
+
this.logger.error("Error creating result click collector: ", e);
|
|
1452
|
+
return () => {};
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/** Routes a resolved declarative click through the high-level API. */
|
|
1456
|
+
emitResolvedResultClick(resolved) {
|
|
1457
|
+
const { actionName: resolvedAction, ...data } = resolved;
|
|
1458
|
+
const actionName = resolvedAction !== null && resolvedAction !== void 0 ? resolvedAction : "click";
|
|
1459
|
+
if (actionName === "click" && resolved.queryId && typeof resolved.ordinal === "number") {
|
|
1460
|
+
this.trackResultClick({
|
|
1461
|
+
...data,
|
|
1462
|
+
objectId: resolved.objectId,
|
|
1463
|
+
ordinal: resolved.ordinal,
|
|
1464
|
+
queryId: resolved.queryId
|
|
1465
|
+
});
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
this.trackResultEvent({
|
|
1469
|
+
...data,
|
|
1470
|
+
actionName
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Binds declarative result-impression collection to the DOM: each element
|
|
1475
|
+
* matching `selector` emits one canonical `impression` event the first time
|
|
1476
|
+
* it enters the viewport, attributed via the same data-attribute convention
|
|
1477
|
+
* as {@link Tracker.trackResultClicks} (or a custom `resolve`). Elements
|
|
1478
|
+
* added or re-rendered after `start()` are discovered automatically.
|
|
1479
|
+
*
|
|
1480
|
+
* @returns a function that stops this collection again.
|
|
1481
|
+
*/
|
|
1482
|
+
trackResultImpressions(options) {
|
|
1483
|
+
try {
|
|
1484
|
+
const collector = new ResultImpressionCollector(options, (items, queryId, query) => this.trackResultImpression({
|
|
1485
|
+
items,
|
|
1486
|
+
queryId,
|
|
1487
|
+
query
|
|
1488
|
+
}));
|
|
1489
|
+
return this.registerCollector(collector);
|
|
1490
|
+
} catch (e) {
|
|
1491
|
+
this.logger.error("Error creating result impression collector: ", e);
|
|
1492
|
+
return () => {};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Adds a sink that receives every emitted event (e.g. mirroring into your
|
|
1497
|
+
* own analytics, or a test spy). Returns a disposer that removes it again.
|
|
1498
|
+
*/
|
|
1499
|
+
addSink(sink) {
|
|
1500
|
+
try {
|
|
1501
|
+
this.extraSinks.add(sink);
|
|
1502
|
+
if (this.started && this.sink) this.sink.add(sink);
|
|
1503
|
+
return () => {
|
|
1504
|
+
var _this$sink;
|
|
1505
|
+
this.extraSinks.delete(sink);
|
|
1506
|
+
(_this$sink = this.sink) === null || _this$sink === void 0 || _this$sink.delete(sink);
|
|
1507
|
+
};
|
|
1508
|
+
} catch (e) {
|
|
1509
|
+
this.logger.error("Error adding sink: ", e);
|
|
1510
|
+
return () => {};
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* starts the tracker and attaches all collectors.
|
|
1515
|
+
*/
|
|
1516
|
+
start() {
|
|
1517
|
+
try {
|
|
1518
|
+
if (this.started) return;
|
|
1519
|
+
const { endpointHost, siteId } = this.options;
|
|
1520
|
+
if (endpointHost && !siteId) this.logger.error("Tracker.start: endpointHost is set but siteId is missing. Every event will be dropped by the server. Set `siteId` to the public Site identifier issued by Releval.");
|
|
1521
|
+
this._sessionManager.activity();
|
|
1522
|
+
this.sink = this.buildSink();
|
|
1523
|
+
this.dispatcher = new DefaultDispatcher(this.enrichers, this.sink, this.logger);
|
|
1524
|
+
this.collectors.forEach((collector) => {
|
|
1525
|
+
try {
|
|
1526
|
+
this.detaches.set(collector, collector.attach(this.dispatcher));
|
|
1527
|
+
} catch (e) {
|
|
1528
|
+
this.logger.error("Error attaching collector: ", e);
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
this.started = true;
|
|
1532
|
+
this.hasEverStarted = true;
|
|
1533
|
+
if (this.preStartBuffer.length > 0) {
|
|
1534
|
+
const buffered = this.preStartBuffer.splice(0);
|
|
1535
|
+
this.logger.debug(`ubi: replaying ${buffered.length} event(s) dispatched before start()`);
|
|
1536
|
+
for (const event of buffered) this.dispatcher.dispatch(event);
|
|
1537
|
+
}
|
|
1538
|
+
} catch (e) {
|
|
1539
|
+
this.logger.error("Error starting tracker: ", e);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Builds the effective sink from the configured endpoint plus any user sinks.
|
|
1544
|
+
* ConsoleSink is used only when there is neither an endpoint nor a user sink
|
|
1545
|
+
* (the development default). The result is always an AggregateSink so its
|
|
1546
|
+
* reference stays stable across addSink() calls.
|
|
1547
|
+
*/
|
|
1548
|
+
buildSink() {
|
|
1549
|
+
const { endpointHost } = this.options;
|
|
1550
|
+
const sinks = /* @__PURE__ */ new Set();
|
|
1551
|
+
if (endpointHost) {
|
|
1552
|
+
this.batchSink = new BatchSink({
|
|
1553
|
+
endpointHost,
|
|
1554
|
+
storage: this.localStorage,
|
|
1555
|
+
logger: this.logger
|
|
1556
|
+
});
|
|
1557
|
+
sinks.add(this.batchSink);
|
|
1558
|
+
}
|
|
1559
|
+
for (const sink of this.extraSinks) sinks.add(sink);
|
|
1560
|
+
if (sinks.size === 0) sinks.add(new ConsoleSink());
|
|
1561
|
+
return new AggregateSink(sinks, this.logger);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Dispatches an event through the tracker pipeline (enrichers → sinks).
|
|
1565
|
+
* Use this to send custom events that aren't captured by a collector.
|
|
1566
|
+
* Safe to call before start(): the event is buffered (bounded), stamped
|
|
1567
|
+
* with the timestamp of the moment it happened, and replayed once start()
|
|
1568
|
+
* runs.
|
|
1569
|
+
* @param event The event to dispatch.
|
|
1570
|
+
*/
|
|
1571
|
+
dispatch(event) {
|
|
1572
|
+
if (!this.started || !this.dispatcher) {
|
|
1573
|
+
if (this.hasEverStarted) {
|
|
1574
|
+
this.logger.debug("ubi: tracker is stopped; event dropped", event);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (!event.timestamp) event.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1578
|
+
if (!event.user_id && this._userId) event.user_id = this._userId;
|
|
1579
|
+
if (this.preStartBuffer.length >= PRE_START_BUFFER_LIMIT) {
|
|
1580
|
+
if (!this.hasWarnedPreStartOverflow) {
|
|
1581
|
+
this.hasWarnedPreStartOverflow = true;
|
|
1582
|
+
this.logger.warn(`More than ${PRE_START_BUFFER_LIMIT} events were dispatched before start(); dropping the oldest. Call start() earlier.`);
|
|
1583
|
+
}
|
|
1584
|
+
this.preStartBuffer.shift();
|
|
1585
|
+
}
|
|
1586
|
+
this.preStartBuffer.push(event);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
this.dispatcher.dispatch(event);
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Forces immediate delivery of any queued events, rather than waiting for the
|
|
1593
|
+
* next batch interval. Useful before a critical action or a hard navigation.
|
|
1594
|
+
* Resolves once the current batch has been sent (or scheduled for retry).
|
|
1595
|
+
* A no-op that resolves immediately when there is no batching sink (events go
|
|
1596
|
+
* to the console) or the tracker is stopped.
|
|
1597
|
+
*/
|
|
1598
|
+
flush() {
|
|
1599
|
+
try {
|
|
1600
|
+
var _this$batchSink$flush, _this$batchSink;
|
|
1601
|
+
return (_this$batchSink$flush = (_this$batchSink = this.batchSink) === null || _this$batchSink === void 0 ? void 0 : _this$batchSink.flush()) !== null && _this$batchSink$flush !== void 0 ? _this$batchSink$flush : Promise.resolve();
|
|
1602
|
+
} catch (e) {
|
|
1603
|
+
this.logger.error("Error flushing events: ", e);
|
|
1604
|
+
return Promise.resolve();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* stops the tracker and detaches all collectors.
|
|
1609
|
+
*/
|
|
1610
|
+
stop() {
|
|
1611
|
+
try {
|
|
1612
|
+
if (!this.started) return;
|
|
1613
|
+
this.detaches.forEach((detach) => {
|
|
1614
|
+
try {
|
|
1615
|
+
detach();
|
|
1616
|
+
} catch (e) {
|
|
1617
|
+
this.logger.error("Error detaching collector: ", e);
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
this.detaches.clear();
|
|
1621
|
+
if (this.batchSink) this.batchSink.dispose();
|
|
1622
|
+
this.sink = void 0;
|
|
1623
|
+
this.batchSink = void 0;
|
|
1624
|
+
this.dispatcher = void 0;
|
|
1625
|
+
this.started = false;
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
this.logger.error("Error stopping tracker: ", e);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
//#endregion
|
|
1632
|
+
Object.defineProperty(exports, "BatchSink", {
|
|
1633
|
+
enumerable: true,
|
|
1634
|
+
get: function() {
|
|
1635
|
+
return BatchSink;
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
Object.defineProperty(exports, "ConsoleLogger", {
|
|
1639
|
+
enumerable: true,
|
|
1640
|
+
get: function() {
|
|
1641
|
+
return ConsoleLogger;
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
Object.defineProperty(exports, "ConsoleSink", {
|
|
1645
|
+
enumerable: true,
|
|
1646
|
+
get: function() {
|
|
1647
|
+
return ConsoleSink;
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
Object.defineProperty(exports, "Tracker", {
|
|
1651
|
+
enumerable: true,
|
|
1652
|
+
get: function() {
|
|
1653
|
+
return Tracker;
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
Object.defineProperty(exports, "readResultData", {
|
|
1657
|
+
enumerable: true,
|
|
1658
|
+
get: function() {
|
|
1659
|
+
return readResultData;
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
//# sourceMappingURL=tracker.cjs.map
|