@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.
@@ -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