@obsrviq/tracker 0.3.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/dist/index.js ADDED
@@ -0,0 +1,1827 @@
1
+ import { gzipSync } from 'fflate';
2
+ import { record } from 'rrweb';
3
+
4
+ // src/transport.ts
5
+ var MAX_BUFFER_EVENTS = 5e3;
6
+ var Transport = class {
7
+ constructor(opts) {
8
+ this.opts = opts;
9
+ this.buffer = [];
10
+ this.sending = false;
11
+ this.retryDelay = 1e3;
12
+ this.stopped = false;
13
+ this.onVisibility = () => {
14
+ if (document.visibilityState === "hidden") void this.flush("hidden");
15
+ };
16
+ this.onPageHide = () => {
17
+ void this.flush("pagehide");
18
+ };
19
+ this.seq = opts.startSeq ?? 0;
20
+ }
21
+ start() {
22
+ this.timer = setInterval(() => void this.flush("timer"), this.opts.flushIntervalMs);
23
+ document.addEventListener("visibilitychange", this.onVisibility, { capture: true });
24
+ window.addEventListener("pagehide", this.onPageHide, { capture: true });
25
+ }
26
+ enqueue(event) {
27
+ if (this.stopped) return;
28
+ this.buffer.push(event);
29
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) {
30
+ void this.flush("backpressure");
31
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) {
32
+ this.buffer = this.buffer.filter((e, i) => e.type !== "dom" || i % 2 === 0);
33
+ }
34
+ }
35
+ }
36
+ /** Build, compress, and send the current buffer. Returns true on success. */
37
+ async flush(reason) {
38
+ if (this.sending || this.buffer.length === 0) return true;
39
+ this.sending = true;
40
+ const events = this.buffer;
41
+ this.buffer = [];
42
+ let batch = {
43
+ siteKey: this.opts.siteKey,
44
+ sessionId: this.opts.sessionId,
45
+ seq: this.seq,
46
+ meta: this.opts.getMeta(),
47
+ events
48
+ };
49
+ if (this.opts.beforeSend) {
50
+ try {
51
+ batch = this.opts.beforeSend(batch);
52
+ } catch {
53
+ batch = null;
54
+ }
55
+ }
56
+ if (!batch) {
57
+ this.sending = false;
58
+ return true;
59
+ }
60
+ this.opts.onSeqDispatch?.(this.seq + 1);
61
+ const json = JSON.stringify(batch);
62
+ const gz = gzipSync(new TextEncoder().encode(json));
63
+ const ok = await this.send(gz, reason === "pagehide" || reason === "hidden");
64
+ this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
65
+ if (ok) {
66
+ this.seq++;
67
+ this.retryDelay = 1e3;
68
+ this.sending = false;
69
+ return true;
70
+ }
71
+ this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
72
+ this.sending = false;
73
+ if (!this.stopped) {
74
+ const jitter = this.retryDelay * (0.5 + Math.random());
75
+ this.retryDelay = Math.min(this.retryDelay * 2, 3e4);
76
+ setTimeout(() => void this.flush("timer"), jitter);
77
+ }
78
+ return false;
79
+ }
80
+ async send(bytes, unloading) {
81
+ const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
82
+ const headers = {
83
+ "content-type": "application/octet-stream",
84
+ "x-obsrviq-key": this.opts.siteKey
85
+ };
86
+ const body = bytes;
87
+ if (unloading) {
88
+ const blob = new Blob([bytes], { type: "application/octet-stream" });
89
+ if (typeof navigator.sendBeacon === "function") {
90
+ try {
91
+ if (navigator.sendBeacon(url, blob)) return true;
92
+ } catch {
93
+ }
94
+ }
95
+ try {
96
+ void fetch(url, { method: "POST", body, headers, keepalive: true, credentials: "omit", mode: "cors" }).catch(
97
+ () => {
98
+ }
99
+ );
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+ const ctrl = new AbortController();
106
+ const timeout = setTimeout(() => ctrl.abort(), 15e3);
107
+ try {
108
+ const res = await fetch(url, {
109
+ method: "POST",
110
+ body,
111
+ headers,
112
+ credentials: "omit",
113
+ mode: "cors",
114
+ signal: ctrl.signal
115
+ });
116
+ return res.ok;
117
+ } catch {
118
+ return false;
119
+ } finally {
120
+ clearTimeout(timeout);
121
+ }
122
+ }
123
+ stop() {
124
+ this.stopped = true;
125
+ if (this.timer) clearInterval(this.timer);
126
+ document.removeEventListener("visibilitychange", this.onVisibility, { capture: true });
127
+ window.removeEventListener("pagehide", this.onPageHide, { capture: true });
128
+ void this.flush("manual");
129
+ }
130
+ };
131
+
132
+ // src/util.ts
133
+ function uuid() {
134
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
135
+ return crypto.randomUUID();
136
+ }
137
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
138
+ const r = Math.random() * 16 | 0;
139
+ const v = c === "x" ? r : r & 3 | 8;
140
+ return v.toString(16);
141
+ });
142
+ }
143
+ var Clock = class {
144
+ /** @param resumeStartedAt epoch ms of the original session start when RESUMING a
145
+ * continued session; omit for a fresh session.
146
+ * @param floorT highest `t` the prior page reached — guarantees this page's
147
+ * timeline starts after it, so a backward wall-clock jump between loads can't
148
+ * produce overlapping timestamps (which would corrupt replay ordering). */
149
+ constructor(resumeStartedAt, floorT) {
150
+ const wallNow = Date.now();
151
+ this.startedAt = resumeStartedAt ?? wallNow;
152
+ this.perfStart = typeof performance !== "undefined" ? performance.now() : 0;
153
+ this.baseT = Math.max(0, wallNow - this.startedAt, floorT ?? 0);
154
+ }
155
+ /** ms since the (original) session start — the master clock `t`. */
156
+ now() {
157
+ const p = typeof performance !== "undefined" ? performance.now() : Date.now() - this.startedAt;
158
+ return Math.max(0, this.baseT + Math.round(p - this.perfStart));
159
+ }
160
+ /** wall-clock epoch ms for display (`ts`). */
161
+ wall() {
162
+ return Date.now();
163
+ }
164
+ /** Convert a performance-timeline timestamp (since timeOrigin) to session t. */
165
+ fromPerf(perfTs) {
166
+ return Math.max(0, this.baseT + Math.round(perfTs - this.perfStart));
167
+ }
168
+ };
169
+ async function sha256Hex(input) {
170
+ if (typeof crypto !== "undefined" && crypto.subtle) {
171
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
172
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
173
+ }
174
+ let h = 0;
175
+ for (let i = 0; i < input.length; i++) h = Math.imul(31, h) + input.charCodeAt(i) | 0;
176
+ return (h >>> 0).toString(16);
177
+ }
178
+ function detectDevice() {
179
+ const ua = navigator.userAgent;
180
+ const viewport = { w: window.innerWidth, h: window.innerHeight };
181
+ let type = "desktop";
182
+ if (/iPad|Tablet/i.test(ua)) type = "tablet";
183
+ else if (/Mobi|Android|iPhone/i.test(ua)) type = "mobile";
184
+ let os;
185
+ if (/Windows/i.test(ua)) os = "Windows";
186
+ else if (/Mac OS X|Macintosh/i.test(ua)) os = "macOS";
187
+ else if (/Android/i.test(ua)) os = "Android";
188
+ else if (/iPhone|iPad|iOS/i.test(ua)) os = "iOS";
189
+ else if (/Linux/i.test(ua)) os = "Linux";
190
+ let browser;
191
+ if (/Edg\//.test(ua)) browser = "Edge";
192
+ else if (/Chrome\//.test(ua)) browser = "Chrome";
193
+ else if (/Firefox\//.test(ua)) browser = "Firefox";
194
+ else if (/Safari\//.test(ua)) browser = "Safari";
195
+ return { type, os, browser, viewport };
196
+ }
197
+ function privacySignalsOptOut() {
198
+ const nav = navigator;
199
+ if (nav.globalPrivacyControl === true) return true;
200
+ if (nav.doNotTrack === "1" || window.doNotTrack === "1") return true;
201
+ return false;
202
+ }
203
+
204
+ // src/recorder.ts
205
+ var INCREMENTAL = 3;
206
+ function instrumentDom(ctx) {
207
+ const stop = record({
208
+ emit(event) {
209
+ const dom = {
210
+ id: uuid(),
211
+ sessionId: ctx.sessionId,
212
+ type: "dom",
213
+ t: ctx.clock.fromPerf(performanceNow()),
214
+ ts: ctx.clock.wall(),
215
+ data: event
216
+ };
217
+ ctx.emit(dom);
218
+ if (event.type === INCREMENTAL) ctx.markActivity();
219
+ },
220
+ maskAllInputs: ctx.config.maskAllInputs,
221
+ // Password inputs stay masked even in no-privacy mode (maskAllInputs:false) —
222
+ // a hard floor so recordings never store plaintext passwords.
223
+ maskInputOptions: { password: true },
224
+ maskTextSelector: ctx.config.maskTextSelector,
225
+ blockSelector: ctx.config.blockSelector,
226
+ recordCanvas: ctx.config.recordCanvas,
227
+ // Periodic full snapshots → the player can start replay from the nearest
228
+ // checkpoint, enabling progressive buffering + reliable seeking (§8.6).
229
+ checkoutEveryNms: 3e4,
230
+ sampling: {
231
+ // Don't record every mousemove point; sample for a smaller payload.
232
+ mousemove: 50,
233
+ scroll: 150,
234
+ input: "last"
235
+ }
236
+ });
237
+ return () => {
238
+ try {
239
+ stop?.();
240
+ } catch {
241
+ }
242
+ };
243
+ }
244
+ function performanceNow() {
245
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
246
+ }
247
+
248
+ // src/mask.ts
249
+ var EMAIL = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
250
+ var CC = /\b(?:\d[ -]*?){13,19}\b/g;
251
+ var SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
252
+ var LONG_DIGITS = /\b\d{9,}\b/g;
253
+ var SENSITIVE_KEYS = /(pass(word)?|secret|token|api[-_]?key|authorization|cookie|ssn|card|cvv|pin|otp)/i;
254
+ function maskString(input) {
255
+ return input.replace(EMAIL, "\xABemail\xBB").replace(CC, (m) => luhnish(m) ? "\xABcard\xBB" : m).replace(SSN, "\xABssn\xBB").replace(LONG_DIGITS, "\xABnumber\xBB");
256
+ }
257
+ function luhnish(s) {
258
+ const digits = s.replace(/\D/g, "");
259
+ return digits.length >= 13 && digits.length <= 19;
260
+ }
261
+ function isSensitiveKey(key) {
262
+ return SENSITIVE_KEYS.test(key);
263
+ }
264
+ function maskValue(value, depth = 0) {
265
+ if (depth > 6) return "\xABdepth\xBB";
266
+ if (typeof value === "string") return maskString(value);
267
+ if (value === null || typeof value !== "object") return value;
268
+ if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
269
+ const out = {};
270
+ for (const [k, v] of Object.entries(value)) {
271
+ out[k] = isSensitiveKey(k) ? "\xABredacted\xBB" : maskValue(v, depth + 1);
272
+ }
273
+ return out;
274
+ }
275
+
276
+ // src/serialize.ts
277
+ var DEFAULT_MAX_BYTES = 8 * 1024;
278
+ function byteLen(s) {
279
+ return new Blob([s]).size;
280
+ }
281
+ function truncate(s, maxBytes) {
282
+ if (byteLen(s) <= maxBytes) return { value: s, truncated: false };
283
+ const approx = Math.max(16, Math.floor(maxBytes * 0.95));
284
+ return { value: s.slice(0, approx), truncated: true };
285
+ }
286
+ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
287
+ if (arg === null) return { kind: "primitive", value: null };
288
+ const type = typeof arg;
289
+ if (type === "number" || type === "boolean") {
290
+ return { kind: "primitive", value: arg };
291
+ }
292
+ if (type === "string") {
293
+ const masked = maskString(arg);
294
+ const { value, truncated } = truncate(masked, maxBytes);
295
+ return { kind: "string", value, truncated };
296
+ }
297
+ if (arg instanceof Error) {
298
+ return {
299
+ kind: "error",
300
+ name: arg.name,
301
+ message: maskString(arg.message),
302
+ stack: arg.stack ? maskString(arg.stack) : void 0
303
+ };
304
+ }
305
+ if (arg instanceof Element) {
306
+ return { kind: "dom", selector: cssPath(arg) };
307
+ }
308
+ if (type === "object" || type === "function" || type === "symbol" || type === "bigint") {
309
+ const masked = maskValue(arg);
310
+ let json;
311
+ let truncated = false;
312
+ try {
313
+ const full = JSON.stringify(masked, safeReplacer());
314
+ const cut = truncate(full ?? "undefined", maxBytes);
315
+ json = cut.value;
316
+ truncated = cut.truncated;
317
+ } catch {
318
+ json = void 0;
319
+ }
320
+ return { kind: "object", preview: previewOf(masked), json, truncated };
321
+ }
322
+ return { kind: "primitive", value: String(arg) };
323
+ }
324
+ function safeReplacer() {
325
+ const seen = /* @__PURE__ */ new WeakSet();
326
+ return (_key, value) => {
327
+ if (typeof value === "bigint") return `${value.toString()}n`;
328
+ if (typeof value === "function") return `\u0192 ${value.name || "anonymous"}`;
329
+ if (typeof value === "object" && value !== null) {
330
+ if (seen.has(value)) return "\xABcircular\xBB";
331
+ seen.add(value);
332
+ }
333
+ return value;
334
+ };
335
+ }
336
+ function previewOf(value) {
337
+ if (Array.isArray(value)) return `Array(${value.length})`;
338
+ if (value && typeof value === "object") {
339
+ const keys = Object.keys(value);
340
+ const head = keys.slice(0, 4).join(", ");
341
+ return `{ ${head}${keys.length > 4 ? ", \u2026" : ""} }`;
342
+ }
343
+ return String(value);
344
+ }
345
+ function cssPath(el) {
346
+ const parts = [];
347
+ let node = el;
348
+ let depth = 0;
349
+ while (node && node.nodeType === 1 && depth < 5) {
350
+ let part = node.tagName.toLowerCase();
351
+ if (node.id) {
352
+ part += `#${node.id}`;
353
+ parts.unshift(part);
354
+ break;
355
+ }
356
+ const cls = (node.getAttribute("class") || "").trim().split(/\s+/).filter(Boolean)[0];
357
+ if (cls) part += `.${cls}`;
358
+ parts.unshift(part);
359
+ node = node.parentElement;
360
+ depth++;
361
+ }
362
+ return parts.join(" > ");
363
+ }
364
+ function parseSource(stack) {
365
+ if (!stack) return void 0;
366
+ const lines = stack.split("\n").map((l) => l.trim());
367
+ for (const line of lines) {
368
+ const m = line.match(/\((https?:\/\/[^)]+):(\d+):(\d+)\)/) || line.match(/at (https?:\/\/\S+):(\d+):(\d+)/) || line.match(/(https?:\/\/\S+):(\d+):(\d+)/);
369
+ if (m) return { url: m[1], line: Number(m[2]), col: Number(m[3]) };
370
+ }
371
+ return void 0;
372
+ }
373
+
374
+ // src/console.ts
375
+ var LEVELS = ["log", "info", "warn", "error", "debug"];
376
+ var MAX_PER_SEC = 100;
377
+ function instrumentConsole(ctx) {
378
+ const original = {};
379
+ let windowStart = ctx.clock.now();
380
+ let inWindow = 0;
381
+ let suppressed = 0;
382
+ function rateLimited() {
383
+ const now = ctx.clock.now();
384
+ if (now - windowStart >= 1e3) {
385
+ if (suppressed > 0) {
386
+ emitConsole("warn", [
387
+ { kind: "string", value: `Obsrviq: suppressed ${suppressed} console messages (flood cap)` }
388
+ ]);
389
+ suppressed = 0;
390
+ }
391
+ windowStart = now;
392
+ inWindow = 0;
393
+ }
394
+ if (inWindow >= MAX_PER_SEC) {
395
+ suppressed++;
396
+ return true;
397
+ }
398
+ inWindow++;
399
+ return false;
400
+ }
401
+ function emitConsole(level, args, stack) {
402
+ const source = parseSource(stack);
403
+ const e = {
404
+ id: uuid(),
405
+ sessionId: ctx.sessionId,
406
+ type: "console",
407
+ t: ctx.clock.now(),
408
+ ts: ctx.clock.wall(),
409
+ level,
410
+ args,
411
+ stack: level === "error" ? stack : void 0,
412
+ source
413
+ };
414
+ ctx.emit(e);
415
+ }
416
+ for (const level of LEVELS) {
417
+ const orig = console[level];
418
+ original[level] = orig;
419
+ console[level] = (...args) => {
420
+ try {
421
+ orig.apply(console, args);
422
+ } catch {
423
+ }
424
+ try {
425
+ if (rateLimited()) return;
426
+ const serialized = args.map((a) => serializeArg(a, ctx.config.maxArgBytes));
427
+ let stack;
428
+ if (level === "error") {
429
+ const errArg = args.find((a) => a instanceof Error);
430
+ stack = errArg?.stack ?? new Error().stack;
431
+ }
432
+ emitConsole(level, serialized, stack);
433
+ } catch {
434
+ }
435
+ };
436
+ }
437
+ return () => {
438
+ for (const level of LEVELS) {
439
+ if (original[level]) console[level] = original[level];
440
+ }
441
+ };
442
+ }
443
+ function errorToConsoleEvent(ctx, err) {
444
+ return {
445
+ id: uuid(),
446
+ sessionId: ctx.sessionId,
447
+ type: "console",
448
+ t: err.t,
449
+ ts: err.ts,
450
+ level: "error",
451
+ args: [{ kind: "error", name: err.name, message: err.message, stack: err.stack }],
452
+ stack: err.stack,
453
+ source: err.source
454
+ };
455
+ }
456
+
457
+ // src/network.ts
458
+ var BODY_CAP = 4 * 1024;
459
+ var HEADER_ALLOW = /* @__PURE__ */ new Set([
460
+ "content-type",
461
+ "content-length",
462
+ "accept",
463
+ "accept-encoding",
464
+ "cache-control",
465
+ "etag",
466
+ "last-modified",
467
+ "content-encoding",
468
+ "x-request-id",
469
+ "x-correlation-id",
470
+ "server",
471
+ "vary"
472
+ ]);
473
+ function redactUrl(raw) {
474
+ try {
475
+ const u = new URL(raw, location.href);
476
+ if (u.search) u.search = "?\u2026";
477
+ return u.toString();
478
+ } catch {
479
+ return raw.split("?")[0] ?? raw;
480
+ }
481
+ }
482
+ function classifyHttp(status) {
483
+ if (status === 0) return "network";
484
+ if (status >= 500) return "http_5xx";
485
+ if (status >= 400) return "http_4xx";
486
+ return void 0;
487
+ }
488
+ function pickHeaders(redact, entries) {
489
+ const out = {};
490
+ const redactSet = new Set(redact.map((h) => h.toLowerCase()));
491
+ for (const [k, v] of entries) {
492
+ const key = k.toLowerCase();
493
+ if (redactSet.has(key)) out[key] = "redacted";
494
+ else if (HEADER_ALLOW.has(key)) out[key] = v;
495
+ }
496
+ return out;
497
+ }
498
+ function bodyPreview(text, contentType) {
499
+ if (text == null) return void 0;
500
+ const masked = maskString(text);
501
+ const truncated = masked.length > BODY_CAP;
502
+ return {
503
+ contentType,
504
+ preview: truncated ? masked.slice(0, BODY_CAP) : masked,
505
+ truncated,
506
+ redacted: masked !== text
507
+ };
508
+ }
509
+ function enrichTiming(ctx, url, base) {
510
+ try {
511
+ const entries = performance.getEntriesByType("resource");
512
+ const match = entries.filter((e) => e.name === url).pop();
513
+ if (!match) return base;
514
+ const t = { ...base };
515
+ if (match.domainLookupEnd && match.domainLookupStart)
516
+ t.dns = Math.max(0, match.domainLookupEnd - match.domainLookupStart);
517
+ if (match.connectEnd && match.connectStart)
518
+ t.tcp = Math.max(0, match.connectEnd - match.connectStart);
519
+ if (match.secureConnectionStart)
520
+ t.tls = Math.max(0, match.connectEnd - match.secureConnectionStart);
521
+ if (match.responseStart && match.requestStart)
522
+ t.ttfb = Math.max(0, match.responseStart - match.requestStart);
523
+ if (match.responseEnd && match.responseStart)
524
+ t.download = Math.max(0, match.responseEnd - match.responseStart);
525
+ return t;
526
+ } catch {
527
+ return base;
528
+ }
529
+ }
530
+ function instrumentNetwork(ctx) {
531
+ const teardowns = [];
532
+ teardowns.push(wrapFetch(ctx));
533
+ teardowns.push(wrapXhr(ctx));
534
+ teardowns.push(wrapBeacon(ctx));
535
+ teardowns.push(observeResources(ctx));
536
+ return () => teardowns.forEach((t) => t());
537
+ }
538
+ function wrapFetch(ctx) {
539
+ const orig = window.fetch;
540
+ if (!orig) return () => {
541
+ };
542
+ window.fetch = async function(input, init) {
543
+ const rawUrl = input instanceof Request ? input.url : String(input);
544
+ if (rawUrl.includes("/v1/batch")) return orig.call(window, input, init);
545
+ const startT = ctx.clock.now();
546
+ const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
547
+ const url = redactUrl(rawUrl);
548
+ const reqHeaders = pickHeaders(
549
+ ctx.config.redactHeaders,
550
+ headerEntries(init?.headers ?? (input instanceof Request ? input.headers : void 0))
551
+ );
552
+ const emit = (partial) => {
553
+ const endT = ctx.clock.now();
554
+ const timing = enrichTiming(ctx, rawUrl, { startT, endT, duration: endT - startT });
555
+ const ev = {
556
+ id: uuid(),
557
+ sessionId: ctx.sessionId,
558
+ type: "network",
559
+ t: startT,
560
+ ts: ctx.clock.wall(),
561
+ initiator: "fetch",
562
+ method,
563
+ url,
564
+ status: 0,
565
+ ok: false,
566
+ resourceType: "fetch",
567
+ requestHeaders: reqHeaders,
568
+ timing,
569
+ ...partial
570
+ };
571
+ ctx.emit(ev);
572
+ ctx.markActivity();
573
+ };
574
+ try {
575
+ const res = await orig.call(window, input, init);
576
+ const status = res.status;
577
+ const respHeaders = pickHeaders(ctx.config.redactHeaders, res.headers.entries());
578
+ const len = res.headers.get("content-length");
579
+ let responseBody;
580
+ if (ctx.config.captureNetworkBodies && isTexty(res.headers.get("content-type"))) {
581
+ try {
582
+ const text = await res.clone().text();
583
+ responseBody = bodyPreview(text, res.headers.get("content-type") || void 0);
584
+ } catch {
585
+ }
586
+ }
587
+ emit({
588
+ status,
589
+ ok: res.ok,
590
+ responseSize: len ? Number(len) : void 0,
591
+ responseHeaders: respHeaders,
592
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(stringifyBody(init?.body), void 0) : void 0,
593
+ responseBody,
594
+ error: classifyHttp(status)
595
+ });
596
+ return res;
597
+ } catch (err) {
598
+ emit({ status: 0, ok: false, error: classifyFetchError(err) });
599
+ throw err;
600
+ }
601
+ };
602
+ return () => {
603
+ window.fetch = orig;
604
+ };
605
+ }
606
+ function classifyFetchError(err) {
607
+ const name = err?.name;
608
+ if (name === "AbortError") return "aborted";
609
+ const msg = String(err?.message || "").toLowerCase();
610
+ if (msg.includes("cors")) return "cors";
611
+ if (msg.includes("timeout")) return "timeout";
612
+ return "network";
613
+ }
614
+ function wrapXhr(ctx) {
615
+ const XHR = XMLHttpRequest.prototype;
616
+ const origOpen = XHR.open;
617
+ const origSend = XHR.send;
618
+ const origSetHeader = XHR.setRequestHeader;
619
+ const META = /* @__PURE__ */ new WeakMap();
620
+ XHR.open = function(method, url, ...rest) {
621
+ const rawUrl = String(url);
622
+ META.set(this, {
623
+ method: String(method).toUpperCase(),
624
+ rawUrl,
625
+ url: redactUrl(rawUrl),
626
+ startT: 0,
627
+ reqHeaders: {}
628
+ });
629
+ return origOpen.apply(this, [method, url, ...rest]);
630
+ };
631
+ XHR.setRequestHeader = function(name, value) {
632
+ const meta = META.get(this);
633
+ if (meta) {
634
+ const key = name.toLowerCase();
635
+ if (ctx.config.redactHeaders.map((h) => h.toLowerCase()).includes(key))
636
+ meta.reqHeaders[key] = "redacted";
637
+ else if (HEADER_ALLOW.has(key)) meta.reqHeaders[key] = value;
638
+ }
639
+ return origSetHeader.apply(this, [name, value]);
640
+ };
641
+ XHR.send = function(body) {
642
+ const meta = META.get(this);
643
+ if (meta) {
644
+ meta.startT = ctx.clock.now();
645
+ if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
646
+ const finish = (error) => {
647
+ const endT = ctx.clock.now();
648
+ const timing = enrichTiming(ctx, meta.rawUrl, {
649
+ startT: meta.startT,
650
+ endT,
651
+ duration: endT - meta.startT
652
+ });
653
+ const status = this.status;
654
+ const respHeaders = parseRawHeaders(ctx, this.getAllResponseHeaders());
655
+ let responseBody;
656
+ if (ctx.config.captureNetworkBodies && isTexty(this.getResponseHeader("content-type"))) {
657
+ try {
658
+ if (this.responseType === "" || this.responseType === "text") {
659
+ responseBody = bodyPreview(this.responseText, this.getResponseHeader("content-type") || void 0);
660
+ }
661
+ } catch {
662
+ }
663
+ }
664
+ const ev = {
665
+ id: uuid(),
666
+ sessionId: ctx.sessionId,
667
+ type: "network",
668
+ t: meta.startT,
669
+ ts: ctx.clock.wall(),
670
+ initiator: "xhr",
671
+ method: meta.method,
672
+ url: meta.url,
673
+ status,
674
+ ok: status >= 200 && status < 400,
675
+ resourceType: "xhr",
676
+ requestHeaders: meta.reqHeaders,
677
+ responseHeaders: respHeaders,
678
+ responseSize: safeLen(this),
679
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, void 0) : void 0,
680
+ responseBody,
681
+ timing,
682
+ error: error || classifyHttp(status)
683
+ };
684
+ ctx.emit(ev);
685
+ ctx.markActivity();
686
+ };
687
+ this.addEventListener("loadend", () => finish());
688
+ this.addEventListener("error", () => finish("network"));
689
+ this.addEventListener("timeout", () => finish("timeout"));
690
+ this.addEventListener("abort", () => finish("aborted"));
691
+ }
692
+ return origSend.apply(this, [body]);
693
+ };
694
+ return () => {
695
+ XHR.open = origOpen;
696
+ XHR.send = origSend;
697
+ XHR.setRequestHeader = origSetHeader;
698
+ };
699
+ }
700
+ function safeLen(xhr) {
701
+ try {
702
+ if (xhr.responseType === "" || xhr.responseType === "text")
703
+ return new Blob([xhr.responseText]).size;
704
+ } catch {
705
+ }
706
+ const len = xhr.getResponseHeader("content-length");
707
+ return len ? Number(len) : void 0;
708
+ }
709
+ function parseRawHeaders(ctx, raw) {
710
+ const entries = [];
711
+ for (const line of raw.trim().split(/[\r\n]+/)) {
712
+ const idx = line.indexOf(":");
713
+ if (idx > 0) entries.push([line.slice(0, idx).trim(), line.slice(idx + 1).trim()]);
714
+ }
715
+ return pickHeaders(ctx.config.redactHeaders, entries);
716
+ }
717
+ function wrapBeacon(ctx) {
718
+ const orig = navigator.sendBeacon;
719
+ if (!orig) return () => {
720
+ };
721
+ navigator.sendBeacon = function(url, data) {
722
+ const startT = ctx.clock.now();
723
+ let ok = false;
724
+ try {
725
+ ok = orig.call(navigator, url, data);
726
+ } finally {
727
+ const u = String(url);
728
+ if (!u.includes("/v1/batch")) {
729
+ const ev = {
730
+ id: uuid(),
731
+ sessionId: ctx.sessionId,
732
+ type: "network",
733
+ t: startT,
734
+ ts: ctx.clock.wall(),
735
+ initiator: "beacon",
736
+ method: "POST",
737
+ url: redactUrl(u),
738
+ status: ok ? 202 : 0,
739
+ ok,
740
+ resourceType: "other",
741
+ timing: { startT, endT: ctx.clock.now(), duration: 0 },
742
+ error: ok ? void 0 : "network"
743
+ };
744
+ ctx.emit(ev);
745
+ }
746
+ }
747
+ return ok;
748
+ };
749
+ return () => {
750
+ navigator.sendBeacon = orig;
751
+ };
752
+ }
753
+ var RESOURCE_TYPE_MAP = {
754
+ script: "script",
755
+ link: "stylesheet",
756
+ css: "stylesheet",
757
+ img: "image",
758
+ image: "image",
759
+ font: "font",
760
+ audio: "media",
761
+ video: "media",
762
+ track: "media"
763
+ };
764
+ function observeResources(ctx) {
765
+ if (typeof PerformanceObserver === "undefined") return () => {
766
+ };
767
+ let obs;
768
+ try {
769
+ obs = new PerformanceObserver((list) => {
770
+ for (const entry of list.getEntries()) {
771
+ const it = entry.initiatorType;
772
+ if (it === "fetch" || it === "xmlhttprequest" || it === "beacon") continue;
773
+ const resourceType = RESOURCE_TYPE_MAP[it] || "other";
774
+ const startT = ctx.clock.fromPerf(entry.startTime);
775
+ const endT = ctx.clock.fromPerf(entry.responseEnd || entry.startTime);
776
+ const timing = {
777
+ startT,
778
+ endT,
779
+ duration: Math.max(0, endT - startT),
780
+ dns: nz(entry.domainLookupEnd - entry.domainLookupStart),
781
+ tcp: nz(entry.connectEnd - entry.connectStart),
782
+ tls: entry.secureConnectionStart ? nz(entry.connectEnd - entry.secureConnectionStart) : void 0,
783
+ ttfb: nz(entry.responseStart - entry.requestStart),
784
+ download: nz(entry.responseEnd - entry.responseStart)
785
+ };
786
+ const ev = {
787
+ id: uuid(),
788
+ sessionId: ctx.sessionId,
789
+ type: "network",
790
+ t: startT,
791
+ ts: ctx.clock.wall(),
792
+ initiator: "resource",
793
+ method: "GET",
794
+ url: redactUrl(entry.name),
795
+ status: entry.responseStatus || 200,
796
+ ok: !entry.responseStatus || entry.responseStatus < 400,
797
+ resourceType,
798
+ responseSize: entry.transferSize || entry.encodedBodySize || void 0,
799
+ timing
800
+ };
801
+ ctx.emit(ev);
802
+ }
803
+ });
804
+ obs.observe({ type: "resource", buffered: true });
805
+ } catch {
806
+ return () => {
807
+ };
808
+ }
809
+ return () => obs?.disconnect();
810
+ }
811
+ function nz(n) {
812
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : void 0;
813
+ }
814
+ function isTexty(contentType) {
815
+ if (!contentType) return false;
816
+ return /json|text|xml|x-www-form-urlencoded/i.test(contentType);
817
+ }
818
+ function stringifyBody(body) {
819
+ if (body == null) return void 0;
820
+ if (typeof body === "string") return body;
821
+ if (body instanceof URLSearchParams) return body.toString();
822
+ if (body instanceof FormData) {
823
+ const obj = {};
824
+ body.forEach((v, k) => obj[k] = typeof v === "string" ? v : "\xABfile\xBB");
825
+ return JSON.stringify(obj);
826
+ }
827
+ try {
828
+ return JSON.stringify(body);
829
+ } catch {
830
+ return void 0;
831
+ }
832
+ }
833
+ function headerEntries(headers) {
834
+ if (!headers) return [];
835
+ if (headers instanceof Headers) return headers.entries();
836
+ if (Array.isArray(headers)) return headers;
837
+ return Object.entries(headers);
838
+ }
839
+
840
+ // src/errors.ts
841
+ function instrumentErrors(ctx) {
842
+ function emitError(args) {
843
+ const e = {
844
+ id: uuid(),
845
+ sessionId: ctx.sessionId,
846
+ type: "error",
847
+ t: ctx.clock.now(),
848
+ ts: ctx.clock.wall(),
849
+ name: args.name,
850
+ message: maskString(args.message),
851
+ stack: args.stack ? maskString(args.stack) : void 0,
852
+ source: args.source,
853
+ handled: args.handled
854
+ };
855
+ ctx.emit(e);
856
+ ctx.emit(errorToConsoleEvent(ctx, e));
857
+ }
858
+ const onError = (ev) => {
859
+ const err = ev.error;
860
+ emitError({
861
+ name: err?.name || "Error",
862
+ message: err?.message || ev.message || "Unknown error",
863
+ stack: err?.stack,
864
+ source: { url: ev.filename, line: ev.lineno, col: ev.colno },
865
+ handled: false
866
+ });
867
+ };
868
+ const onRejection = (ev) => {
869
+ const reason = ev.reason;
870
+ if (reason instanceof Error) {
871
+ emitError({
872
+ name: reason.name,
873
+ message: reason.message,
874
+ stack: reason.stack,
875
+ source: parseSource(reason.stack),
876
+ handled: false
877
+ });
878
+ } else {
879
+ emitError({
880
+ name: "UnhandledRejection",
881
+ message: typeof reason === "string" ? reason : safeStringify(reason),
882
+ handled: false
883
+ });
884
+ }
885
+ };
886
+ window.addEventListener("error", onError, true);
887
+ window.addEventListener("unhandledrejection", onRejection, true);
888
+ return () => {
889
+ window.removeEventListener("error", onError, true);
890
+ window.removeEventListener("unhandledrejection", onRejection, true);
891
+ };
892
+ }
893
+ function safeStringify(v) {
894
+ try {
895
+ return JSON.stringify(v) ?? String(v);
896
+ } catch {
897
+ return String(v);
898
+ }
899
+ }
900
+
901
+ // src/vitals.ts
902
+ function rate(metric, value) {
903
+ const t = {
904
+ LCP: [2500, 4e3],
905
+ INP: [200, 500],
906
+ CLS: [0.1, 0.25],
907
+ FCP: [1800, 3e3],
908
+ TTFB: [800, 1800]
909
+ };
910
+ const pair = t[metric];
911
+ if (!pair) return void 0;
912
+ if (value <= pair[0]) return "good";
913
+ if (value <= pair[1]) return "needs-improvement";
914
+ return "poor";
915
+ }
916
+ function instrumentVitals(ctx) {
917
+ if (typeof PerformanceObserver === "undefined") return () => {
918
+ };
919
+ const observers = [];
920
+ const emit = (metric, value, detail) => {
921
+ const e = {
922
+ id: uuid(),
923
+ sessionId: ctx.sessionId,
924
+ type: "perf",
925
+ t: ctx.clock.now(),
926
+ ts: ctx.clock.wall(),
927
+ metric,
928
+ value: Math.round(value * 1e3) / 1e3,
929
+ rating: rate(metric, value),
930
+ detail
931
+ };
932
+ ctx.emit(e);
933
+ };
934
+ const obs = (type, cb, extra) => {
935
+ try {
936
+ const o = new PerformanceObserver((list) => cb(list.getEntries()));
937
+ o.observe({ type, buffered: true, ...extra });
938
+ observers.push(o);
939
+ } catch {
940
+ }
941
+ };
942
+ obs("paint", (entries) => {
943
+ for (const e of entries) if (e.name === "first-contentful-paint") emit("FCP", e.startTime);
944
+ });
945
+ let lcp = 0;
946
+ obs("largest-contentful-paint", (entries) => {
947
+ const last = entries[entries.length - 1];
948
+ if (last) lcp = last.startTime;
949
+ });
950
+ let cls = 0;
951
+ obs("layout-shift", (entries) => {
952
+ for (const e of entries) {
953
+ if (!e.hadRecentInput) cls += e.value;
954
+ }
955
+ });
956
+ let inp = 0;
957
+ obs(
958
+ "event",
959
+ (entries) => {
960
+ for (const e of entries) {
961
+ if (e.duration > inp) inp = e.duration;
962
+ }
963
+ },
964
+ { durationThreshold: 40 }
965
+ );
966
+ obs("navigation", (entries) => {
967
+ const nav = entries[0];
968
+ if (!nav) return;
969
+ emit("TTFB", nav.responseStart);
970
+ emit("navigation", nav.duration, {
971
+ domInteractive: Math.round(nav.domInteractive),
972
+ domComplete: Math.round(nav.domComplete),
973
+ loadEvent: Math.round(nav.loadEventEnd)
974
+ });
975
+ });
976
+ const flushFinal = () => {
977
+ if (lcp) emit("LCP", lcp);
978
+ emit("CLS", cls);
979
+ if (inp) emit("INP", inp);
980
+ };
981
+ const onHide = () => {
982
+ if (document.visibilityState === "hidden") flushFinal();
983
+ };
984
+ document.addEventListener("visibilitychange", onHide, true);
985
+ return () => {
986
+ observers.forEach((o) => o.disconnect());
987
+ document.removeEventListener("visibilitychange", onHide, true);
988
+ };
989
+ }
990
+
991
+ // src/interactions.ts
992
+ var RAGE_WINDOW_MS = 1e3;
993
+ var RAGE_THRESHOLD = 3;
994
+ var DEAD_WAIT_MS = 3e3;
995
+ function isInteractive(el) {
996
+ if (!el) return false;
997
+ let node = el;
998
+ for (let i = 0; i < 4 && node; i++) {
999
+ const tag = node.tagName.toLowerCase();
1000
+ if (["a", "button", "input", "select", "textarea", "label", "summary"].includes(tag))
1001
+ return true;
1002
+ if (node.getAttribute("role") === "button" || node.hasAttribute("onclick")) return true;
1003
+ if (node.getAttribute("contenteditable") === "true") return true;
1004
+ node = node.parentElement;
1005
+ }
1006
+ return false;
1007
+ }
1008
+ function instrumentInteractions(ctx) {
1009
+ let recent = [];
1010
+ let lastRageAt = -Infinity;
1011
+ const emitCustom = (name, props, t) => {
1012
+ const e = {
1013
+ id: uuid(),
1014
+ sessionId: ctx.sessionId,
1015
+ type: "custom",
1016
+ t,
1017
+ ts: ctx.clock.wall(),
1018
+ name,
1019
+ props
1020
+ };
1021
+ ctx.emit(e);
1022
+ };
1023
+ const onClick = (ev) => {
1024
+ const target = ev.target;
1025
+ if (!target || !(target instanceof Element)) return;
1026
+ const t = ctx.clock.now();
1027
+ const selector = cssPath(target);
1028
+ recent = recent.filter((r) => t - r.t <= RAGE_WINDOW_MS);
1029
+ recent.push({ el: target, t });
1030
+ const sameTarget = recent.filter((r) => r.el === target);
1031
+ if (sameTarget.length >= RAGE_THRESHOLD && t - lastRageAt > RAGE_WINDOW_MS) {
1032
+ lastRageAt = t;
1033
+ emitCustom("rage_click", { selector, count: sameTarget.length }, t);
1034
+ }
1035
+ if (!isInteractive(target)) {
1036
+ const before = ctx.lastActivityT();
1037
+ window.setTimeout(() => {
1038
+ if (ctx.lastActivityT() <= Math.max(before, t)) {
1039
+ emitCustom("dead_click", { selector }, t);
1040
+ }
1041
+ }, DEAD_WAIT_MS);
1042
+ }
1043
+ };
1044
+ window.addEventListener("click", onClick, true);
1045
+ return () => window.removeEventListener("click", onClick, true);
1046
+ }
1047
+
1048
+ // src/forms.ts
1049
+ var TRACKED = /* @__PURE__ */ new Set(["INPUT", "TEXTAREA", "SELECT"]);
1050
+ var SKIP_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset", "image", "file"]);
1051
+ var MAX_KEY = 80;
1052
+ var MAX_FOCUS_MS = 3e5;
1053
+ var MIN_FOCUS_MS = 100;
1054
+ function isTracked(el) {
1055
+ if (!(el instanceof HTMLElement) || !TRACKED.has(el.tagName)) return false;
1056
+ if (el instanceof HTMLInputElement && SKIP_INPUT_TYPES.has(el.type)) return false;
1057
+ return true;
1058
+ }
1059
+ function controlType(el) {
1060
+ if (el instanceof HTMLTextAreaElement) return "textarea";
1061
+ if (el instanceof HTMLSelectElement) return "select";
1062
+ return el.type || "text";
1063
+ }
1064
+ function isFilled(el) {
1065
+ if (el instanceof HTMLInputElement) {
1066
+ if (el.type === "checkbox" || el.type === "radio") return el.checked;
1067
+ return el.value.trim().length > 0;
1068
+ }
1069
+ if (el instanceof HTMLSelectElement) return el.value !== "";
1070
+ return el.value.trim().length > 0;
1071
+ }
1072
+ function clip(s) {
1073
+ const t = s.trim().replace(/\s+/g, " ");
1074
+ return t.length > MAX_KEY ? t.slice(0, MAX_KEY) : t;
1075
+ }
1076
+ function fieldKey(el) {
1077
+ const name = el.getAttribute("name");
1078
+ if (name) return clip(name);
1079
+ if (el.id) return clip(el.id);
1080
+ const aria = el.getAttribute("aria-label");
1081
+ if (aria) return clip(aria);
1082
+ const labelledby = el.getAttribute("aria-labelledby");
1083
+ if (labelledby) {
1084
+ const lbl = labelledby.split(/\s+/).map((id) => el.ownerDocument.getElementById(id)?.textContent || "").join(" ").trim();
1085
+ if (lbl) return clip(lbl);
1086
+ }
1087
+ if (el.labels && el.labels.length) {
1088
+ const lbl = el.labels[0].textContent?.trim();
1089
+ if (lbl) return clip(lbl);
1090
+ }
1091
+ const ph = el.getAttribute("placeholder");
1092
+ if (ph) return clip(ph);
1093
+ return clip(cssPath(el));
1094
+ }
1095
+ function formKey(el) {
1096
+ const form = el.form;
1097
+ if (form) {
1098
+ if (form.getAttribute("name")) return clip(form.getAttribute("name"));
1099
+ if (form.id) return clip(form.id);
1100
+ const aria = form.getAttribute("aria-label");
1101
+ if (aria) return clip(aria);
1102
+ const action = form.getAttribute("action");
1103
+ if (action) {
1104
+ try {
1105
+ return clip(new URL(action, location.href).pathname);
1106
+ } catch {
1107
+ return clip(action);
1108
+ }
1109
+ }
1110
+ return clip(cssPath(form));
1111
+ }
1112
+ return clip(`page:${location.pathname}`);
1113
+ }
1114
+ function instrumentForms(ctx) {
1115
+ const block = ctx.config.blockSelector;
1116
+ let focused = null;
1117
+ const emit = (name, props) => {
1118
+ const e = {
1119
+ id: uuid(),
1120
+ sessionId: ctx.sessionId,
1121
+ type: "custom",
1122
+ t: ctx.clock.now(),
1123
+ ts: ctx.clock.wall(),
1124
+ name,
1125
+ props
1126
+ };
1127
+ ctx.emit(e);
1128
+ };
1129
+ const blocked = (el) => {
1130
+ try {
1131
+ return !!block && !!el.closest(block);
1132
+ } catch {
1133
+ return false;
1134
+ }
1135
+ };
1136
+ const flush = () => {
1137
+ if (!focused) return;
1138
+ const { el, start, changed } = focused;
1139
+ focused = null;
1140
+ const ms = Math.min(MAX_FOCUS_MS, Math.max(0, ctx.clock.now() - start));
1141
+ if (ms < MIN_FOCUS_MS && !changed) return;
1142
+ emit("form_field", {
1143
+ form: formKey(el),
1144
+ field: fieldKey(el),
1145
+ type: controlType(el),
1146
+ ms,
1147
+ changed,
1148
+ filled: isFilled(el)
1149
+ });
1150
+ };
1151
+ const onFocusIn = (ev) => {
1152
+ const el = ev.target;
1153
+ if (!isTracked(el) || blocked(el)) return;
1154
+ if (focused && focused.el !== el) flush();
1155
+ focused = { el, start: ctx.clock.now(), changed: false };
1156
+ };
1157
+ const onInput = (ev) => {
1158
+ if (focused && ev.target === focused.el) focused.changed = true;
1159
+ };
1160
+ const onFocusOut = (ev) => {
1161
+ if (focused && ev.target === focused.el) flush();
1162
+ };
1163
+ const onSubmit = (ev) => {
1164
+ const form = ev.target;
1165
+ if (!(form instanceof HTMLFormElement) || blocked(form)) return;
1166
+ flush();
1167
+ const probe = form.querySelector("input,textarea,select");
1168
+ const key = probe && !blocked(probe) ? formKey(probe) : clip(form.getAttribute("name") || form.id || cssPath(form));
1169
+ emit("form_submit", { form: key });
1170
+ };
1171
+ document.addEventListener("focusin", onFocusIn, true);
1172
+ document.addEventListener("focusout", onFocusOut, true);
1173
+ document.addEventListener("input", onInput, true);
1174
+ document.addEventListener("submit", onSubmit, true);
1175
+ window.addEventListener("pagehide", flush, true);
1176
+ return () => {
1177
+ flush();
1178
+ document.removeEventListener("focusin", onFocusIn, true);
1179
+ document.removeEventListener("focusout", onFocusOut, true);
1180
+ document.removeEventListener("input", onInput, true);
1181
+ document.removeEventListener("submit", onSubmit, true);
1182
+ window.removeEventListener("pagehide", flush, true);
1183
+ };
1184
+ }
1185
+
1186
+ // src/scroll.ts
1187
+ function instrumentScrollDepth(ctx) {
1188
+ let path = location.pathname;
1189
+ let maxBand = 0;
1190
+ let scheduled = false;
1191
+ const emit = (depth) => {
1192
+ const e = {
1193
+ id: uuid(),
1194
+ sessionId: ctx.sessionId,
1195
+ type: "custom",
1196
+ t: ctx.clock.now(),
1197
+ ts: ctx.clock.wall(),
1198
+ name: "_scroll",
1199
+ props: { path, depth }
1200
+ };
1201
+ ctx.emit(e);
1202
+ };
1203
+ const docHeight = () => {
1204
+ const d = document.documentElement;
1205
+ const b = document.body;
1206
+ return Math.max(d.scrollHeight, b ? b.scrollHeight : 0, d.clientHeight) || 1;
1207
+ };
1208
+ const measure = () => {
1209
+ scheduled = false;
1210
+ if (location.pathname !== path) {
1211
+ path = location.pathname;
1212
+ maxBand = 0;
1213
+ }
1214
+ const vh = window.innerHeight || document.documentElement.clientHeight || 1;
1215
+ const reached = Math.min(1, (window.scrollY + vh) / docHeight());
1216
+ const band = Math.min(100, Math.round(reached * 10) * 10);
1217
+ if (band > maxBand) {
1218
+ maxBand = band;
1219
+ emit(band);
1220
+ }
1221
+ };
1222
+ const onScroll = () => {
1223
+ if (scheduled) return;
1224
+ scheduled = true;
1225
+ requestAnimationFrame(measure);
1226
+ };
1227
+ window.addEventListener("scroll", onScroll, { passive: true, capture: true });
1228
+ measure();
1229
+ return () => {
1230
+ window.removeEventListener("scroll", onScroll, true);
1231
+ };
1232
+ }
1233
+
1234
+ // src/pageviews.ts
1235
+ function instrumentPageviews(ctx) {
1236
+ let lastPath = null;
1237
+ const currentPath = () => {
1238
+ const hash = location.hash;
1239
+ return location.pathname + (hash.startsWith("#/") ? hash : "");
1240
+ };
1241
+ const fire = () => {
1242
+ const path = currentPath();
1243
+ if (path === lastPath) return;
1244
+ const referrer = lastPath ?? "";
1245
+ lastPath = path;
1246
+ const e = {
1247
+ id: uuid(),
1248
+ sessionId: ctx.sessionId,
1249
+ type: "pageview",
1250
+ t: ctx.clock.now(),
1251
+ ts: ctx.clock.wall(),
1252
+ url: location.href,
1253
+ path,
1254
+ title: document.title || void 0,
1255
+ referrer: referrer || void 0
1256
+ };
1257
+ ctx.emit(e);
1258
+ ctx.markActivity();
1259
+ };
1260
+ const origPush = history.pushState;
1261
+ const origReplace = history.replaceState;
1262
+ const wrap = (orig) => function patched(data, unused, url) {
1263
+ const ret = orig.call(this, data, unused, url);
1264
+ queueMicrotask(fire);
1265
+ return ret;
1266
+ };
1267
+ history.pushState = wrap(origPush);
1268
+ history.replaceState = wrap(origReplace);
1269
+ window.addEventListener("popstate", fire);
1270
+ window.addEventListener("hashchange", fire);
1271
+ fire();
1272
+ return () => {
1273
+ history.pushState = origPush;
1274
+ history.replaceState = origReplace;
1275
+ window.removeEventListener("popstate", fire);
1276
+ window.removeEventListener("hashchange", fire);
1277
+ };
1278
+ }
1279
+
1280
+ // src/ui.ts
1281
+ function blockClassFrom(blockSelector) {
1282
+ const m = /\.([A-Za-z0-9_-]+)/.exec(blockSelector || "");
1283
+ const derived = m && m[1] ? m[1] : "";
1284
+ return ["obsrviq-block", derived].filter(Boolean).join(" ");
1285
+ }
1286
+ function makeHost(blockSelector) {
1287
+ const host = document.createElement("div");
1288
+ host.className = blockClassFrom(blockSelector);
1289
+ host.setAttribute("data-obsrviq-ui", "");
1290
+ host.style.cssText = "all:initial";
1291
+ const root = host.attachShadow({ mode: "open" });
1292
+ return { host, root };
1293
+ }
1294
+ function adopt(root, css) {
1295
+ try {
1296
+ const sheet = new CSSStyleSheet();
1297
+ sheet.replaceSync(css);
1298
+ root.adoptedStyleSheets = [sheet];
1299
+ } catch {
1300
+ const style = document.createElement("style");
1301
+ style.textContent = css;
1302
+ root.appendChild(style);
1303
+ }
1304
+ }
1305
+ function mount(host) {
1306
+ (document.body || document.documentElement).appendChild(host);
1307
+ }
1308
+ var ACCENT = "#6e76f1";
1309
+ var CONSENT_CSS = `
1310
+ :host { all: initial; }
1311
+ .card {
1312
+ position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
1313
+ max-width: 340px; box-sizing: border-box;
1314
+ display: grid; grid-template-columns: auto 1fr;
1315
+ grid-template-areas: 'dot txt' 'actions actions'; gap: 9px 12px; padding: 16px 18px;
1316
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
1317
+ color: #f4f5fb; background: #14151b; border: 1px solid rgba(255,255,255,.08);
1318
+ border-radius: 14px; box-shadow: 0 16px 48px rgba(0,0,0,.5);
1319
+ opacity: 0; transform: translateY(10px); transition: opacity .22s ease, transform .22s ease;
1320
+ }
1321
+ .card.in { opacity: 1; transform: none; }
1322
+ .dot { grid-area: dot; width: 9px; height: 9px; margin-top: 5px; border-radius: 50%;
1323
+ background: ${ACCENT}; box-shadow: 0 0 0 4px rgba(110,118,241,.18); }
1324
+ .txt { grid-area: txt; min-width: 0; }
1325
+ .title { font-size: 13.5px; font-weight: 600; line-height: 1.35; }
1326
+ .body { margin-top: 3px; font-size: 12px; line-height: 1.45; color: #a9adbe; }
1327
+ .actions { grid-area: actions; display: flex; gap: 8px; justify-content: flex-end; margin-top: 2px; }
1328
+ button { font: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 15px; border-radius: 9px;
1329
+ cursor: pointer; border: 1px solid transparent; transition: background .15s ease, color .15s ease; }
1330
+ .deny { background: transparent; color: #a9adbe; border-color: rgba(255,255,255,.14); }
1331
+ .deny:hover { color: #f4f5fb; background: rgba(255,255,255,.05); }
1332
+ .allow { background: ${ACCENT}; color: #fff; }
1333
+ .allow:hover { background: #5a62e0; }
1334
+ button:focus-visible { outline: 2px solid ${ACCENT}; outline-offset: 2px; }
1335
+ @media (prefers-reduced-motion: reduce) { .card { transition: none; } }
1336
+ @media (max-width: 480px) { .card { left: 16px; right: 16px; bottom: 16px; max-width: none; } }
1337
+ `;
1338
+ var DOT_CSS = `
1339
+ :host { all: initial; }
1340
+ .recdot {
1341
+ position: fixed; bottom: 16px; right: 16px; z-index: 2147483647;
1342
+ width: 12px; height: 12px; border-radius: 50%;
1343
+ background: #ef4444; box-shadow: 0 0 0 0 rgba(239,68,68,.5);
1344
+ animation: lum-pulse 2s ease-out infinite;
1345
+ }
1346
+ @keyframes lum-pulse {
1347
+ 0% { box-shadow: 0 0 0 0 rgba(239,68,68,.5); }
1348
+ 70% { box-shadow: 0 0 0 9px rgba(239,68,68,0); }
1349
+ 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
1350
+ }
1351
+ @media (prefers-reduced-motion: reduce) { .recdot { animation: none; } }
1352
+ `;
1353
+ function showConsent(blockSelector, text, onAllow, onDeny) {
1354
+ const { host, root } = makeHost(blockSelector);
1355
+ adopt(root, CONSENT_CSS);
1356
+ const t = text ?? {};
1357
+ const card = document.createElement("div");
1358
+ card.className = "card";
1359
+ card.setAttribute("role", "dialog");
1360
+ card.setAttribute("aria-label", t.title || "Session recording consent");
1361
+ const dot = document.createElement("div");
1362
+ dot.className = "dot";
1363
+ dot.setAttribute("aria-hidden", "true");
1364
+ const txt = document.createElement("div");
1365
+ txt.className = "txt";
1366
+ const title = document.createElement("div");
1367
+ title.className = "title";
1368
+ title.textContent = t.title || "We record this session to improve the experience.";
1369
+ const body = document.createElement("div");
1370
+ body.className = "body";
1371
+ body.textContent = t.body || "No passwords or typed input are captured.";
1372
+ txt.append(title, body);
1373
+ const actions = document.createElement("div");
1374
+ actions.className = "actions";
1375
+ const deny = document.createElement("button");
1376
+ deny.type = "button";
1377
+ deny.className = "deny";
1378
+ deny.textContent = t.deny || "Decline";
1379
+ const allow = document.createElement("button");
1380
+ allow.type = "button";
1381
+ allow.className = "allow";
1382
+ allow.textContent = t.allow || "Allow";
1383
+ actions.append(deny, allow);
1384
+ card.append(dot, txt, actions);
1385
+ root.appendChild(card);
1386
+ mount(host);
1387
+ requestAnimationFrame(() => card.classList.add("in"));
1388
+ let done = false;
1389
+ const unmount = () => {
1390
+ if (host.parentNode) host.parentNode.removeChild(host);
1391
+ };
1392
+ const choose = (fn) => () => {
1393
+ if (done) return;
1394
+ done = true;
1395
+ unmount();
1396
+ fn();
1397
+ };
1398
+ allow.addEventListener("click", choose(onAllow));
1399
+ deny.addEventListener("click", choose(onDeny));
1400
+ return unmount;
1401
+ }
1402
+ function showRecordingDot(blockSelector) {
1403
+ const { host, root } = makeHost(blockSelector);
1404
+ adopt(root, DOT_CSS);
1405
+ const dot = document.createElement("div");
1406
+ dot.className = "recdot";
1407
+ dot.setAttribute("role", "status");
1408
+ dot.setAttribute("aria-label", "Session recording in progress");
1409
+ root.appendChild(dot);
1410
+ mount(host);
1411
+ return () => {
1412
+ if (host.parentNode) host.parentNode.removeChild(host);
1413
+ };
1414
+ }
1415
+
1416
+ // src/storage.ts
1417
+ var VISITOR_KEY = "obsrviq.visitor";
1418
+ var SESSION_KEY = "obsrviq.session";
1419
+ function readSession() {
1420
+ const raw = readLS(SESSION_KEY);
1421
+ if (!raw) return null;
1422
+ try {
1423
+ const p = JSON.parse(raw);
1424
+ if (p && typeof p.id === "string" && p.id && typeof p.startedAt === "number") {
1425
+ return {
1426
+ id: p.id,
1427
+ startedAt: p.startedAt,
1428
+ lastActivity: typeof p.lastActivity === "number" ? p.lastActivity : p.startedAt,
1429
+ seq: typeof p.seq === "number" && p.seq >= 0 ? p.seq : 0,
1430
+ lastT: typeof p.lastT === "number" && p.lastT >= 0 ? p.lastT : void 0,
1431
+ userId: typeof p.userId === "string" ? p.userId : void 0
1432
+ };
1433
+ }
1434
+ } catch {
1435
+ }
1436
+ return null;
1437
+ }
1438
+ function writeSession(rec) {
1439
+ writeLS(SESSION_KEY, JSON.stringify(rec));
1440
+ }
1441
+ function clearSession() {
1442
+ if (typeof window === "undefined") return;
1443
+ try {
1444
+ window.localStorage.removeItem(SESSION_KEY);
1445
+ } catch {
1446
+ }
1447
+ }
1448
+ function readLS(key) {
1449
+ if (typeof window === "undefined") return null;
1450
+ try {
1451
+ return window.localStorage.getItem(key);
1452
+ } catch {
1453
+ return null;
1454
+ }
1455
+ }
1456
+ function writeLS(key, value) {
1457
+ if (typeof window === "undefined") return;
1458
+ try {
1459
+ window.localStorage.setItem(key, value);
1460
+ } catch {
1461
+ }
1462
+ }
1463
+ function registerVisit(now) {
1464
+ let rec = null;
1465
+ const raw = readLS(VISITOR_KEY);
1466
+ if (raw) {
1467
+ try {
1468
+ const p = JSON.parse(raw);
1469
+ if (p && typeof p.id === "string" && p.id) {
1470
+ rec = {
1471
+ id: p.id,
1472
+ firstSeenAt: typeof p.firstSeenAt === "number" ? p.firstSeenAt : now,
1473
+ lastSeenAt: typeof p.lastSeenAt === "number" ? p.lastSeenAt : now,
1474
+ visits: typeof p.visits === "number" && p.visits > 0 ? p.visits : 1
1475
+ };
1476
+ }
1477
+ } catch {
1478
+ }
1479
+ }
1480
+ const next = rec ? { ...rec, visits: rec.visits + 1, lastSeenAt: now } : { id: uuid(), firstSeenAt: now, lastSeenAt: now, visits: 1 };
1481
+ writeLS(VISITOR_KEY, JSON.stringify(next));
1482
+ return next;
1483
+ }
1484
+
1485
+ // src/index.ts
1486
+ var DEFAULTS = {
1487
+ maskAllInputs: true,
1488
+ blockSelector: ".obsrviq-block",
1489
+ noPrivacy: false,
1490
+ captureNetworkBodies: false,
1491
+ redactHeaders: ["authorization", "cookie", "set-cookie"],
1492
+ sampleRate: 1,
1493
+ recordCanvas: false,
1494
+ endpoint: "https://in.lumera.app",
1495
+ flushIntervalMs: 5e3,
1496
+ continueSession: false,
1497
+ sessionTimeoutMs: 30 * 60 * 1e3,
1498
+ requireConsent: false,
1499
+ askPermission: false,
1500
+ showRecording: false,
1501
+ maxArgBytes: 8 * 1024
1502
+ };
1503
+ var ObsrviqClient = class {
1504
+ constructor() {
1505
+ this.config = null;
1506
+ this.clock = null;
1507
+ this.transport = null;
1508
+ this.teardowns = [];
1509
+ this.meta = null;
1510
+ this.recording = false;
1511
+ this.consent = null;
1512
+ this.sampledIn = true;
1513
+ this.lastActivity = 0;
1514
+ /** Next batch seq — mirrored from the transport so we can persist continuation. */
1515
+ this.nextSeq = 0;
1516
+ /** Open task spans keyed by task name → the span id + start time, so endTask()
1517
+ * can pair with its startTask(). Re-starting the same name supersedes the
1518
+ * earlier open span (the player pairs by spanId, so this only affects which
1519
+ * start an unqualified end() resolves to). */
1520
+ this.openTasks = /* @__PURE__ */ new Map();
1521
+ this.consentUnmount = null;
1522
+ this.recDotUnmount = null;
1523
+ this.diag = {
1524
+ recording: false,
1525
+ sessionId: null,
1526
+ flushes: 0,
1527
+ bytesSent: 0,
1528
+ eventsSent: 0,
1529
+ eventsQueued: 0
1530
+ };
1531
+ }
1532
+ /** The current session id (null until init). */
1533
+ get sessionId() {
1534
+ return this.diag.sessionId;
1535
+ }
1536
+ init(config) {
1537
+ if (this.config) return;
1538
+ if (!config.siteKey) {
1539
+ console.warn("[obsrviq] init() called without siteKey \u2014 recording disabled.");
1540
+ return;
1541
+ }
1542
+ this.config = { ...DEFAULTS, ...stripUndefined(config) };
1543
+ if (this.config.noPrivacy && config.maskAllInputs === void 0) {
1544
+ this.config.maskAllInputs = false;
1545
+ }
1546
+ if (this.config.askPermission) this.config.requireConsent = true;
1547
+ this.startSession();
1548
+ if (config.userId != null) this.applyUserId(config.userId);
1549
+ if (config.metadata && this.meta) this.meta.metadata = { ...config.metadata };
1550
+ if (privacySignalsOptOut()) {
1551
+ this.consent = false;
1552
+ return;
1553
+ }
1554
+ if (this.config.requireConsent) {
1555
+ if (this.config.askPermission) {
1556
+ this.consentUnmount = showConsent(
1557
+ this.config.blockSelector,
1558
+ this.config.consentText,
1559
+ () => this.setConsent(true),
1560
+ () => this.setConsent(false)
1561
+ );
1562
+ }
1563
+ } else {
1564
+ this.maybeStart();
1565
+ }
1566
+ }
1567
+ /** (Re)create the session id, clock, meta, and transport. Used by init() and
1568
+ * reset(). Does not start recording — callers decide via maybeStart().
1569
+ * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
1570
+ * the initial init() session; FALSE for reset() (logout) — otherwise the fresh
1571
+ * post-logout session would be re-attributed to the user who just logged out. */
1572
+ startSession(seedInitUser = true) {
1573
+ const cfg = this.config;
1574
+ const now = Date.now();
1575
+ let resume = null;
1576
+ if (cfg.continueSession) {
1577
+ const rec = readSession();
1578
+ if (rec && now - rec.lastActivity < cfg.sessionTimeoutMs && !(cfg.userId != null && rec.userId != null && rec.userId !== String(cfg.userId))) {
1579
+ resume = rec;
1580
+ }
1581
+ }
1582
+ const sessionId = resume ? resume.id : uuid();
1583
+ this.clock = resume ? new Clock(resume.startedAt, resume.lastT) : new Clock();
1584
+ this.currentUserId = resume?.userId ?? (seedInitUser && cfg.userId != null ? String(cfg.userId) : void 0);
1585
+ this.nextSeq = resume ? resume.seq : 0;
1586
+ this.diag.sessionId = sessionId;
1587
+ this.meta = {
1588
+ startedAt: this.clock.startedAt,
1589
+ entryUrl: location.href,
1590
+ device: detectDevice()
1591
+ };
1592
+ this.sampledIn = resume ? true : Math.random() < (cfg.sampleRate ?? 1);
1593
+ this.transport = new Transport({
1594
+ endpoint: cfg.endpoint,
1595
+ siteKey: cfg.siteKey,
1596
+ sessionId,
1597
+ getMeta: () => this.meta ?? void 0,
1598
+ beforeSend: cfg.beforeSend,
1599
+ flushIntervalMs: cfg.flushIntervalMs,
1600
+ startSeq: this.nextSeq,
1601
+ onSeqDispatch: (next) => {
1602
+ this.nextSeq = next;
1603
+ this.persistSession();
1604
+ },
1605
+ onFlush: ({ bytes, events, ok }) => {
1606
+ if (ok) {
1607
+ this.diag.flushes++;
1608
+ this.diag.bytesSent += bytes;
1609
+ this.diag.eventsSent += events;
1610
+ }
1611
+ }
1612
+ });
1613
+ this.recording = false;
1614
+ this.diag.recording = false;
1615
+ }
1616
+ /** Persist the session-continuation record (no-op unless continueSession). Only
1617
+ * ever writes for a RECORDING session — a sampled-out or pre-consent session must
1618
+ * not leave a record that a later page would resume (and force back into
1619
+ * recording), and identify() can fire before recording starts. */
1620
+ persistSession() {
1621
+ if (!this.config?.continueSession || !this.recording || !this.clock || !this.diag.sessionId) return;
1622
+ writeSession({
1623
+ id: this.diag.sessionId,
1624
+ startedAt: this.clock.startedAt,
1625
+ lastActivity: Date.now(),
1626
+ seq: this.nextSeq,
1627
+ lastT: this.clock.now(),
1628
+ userId: this.currentUserId
1629
+ });
1630
+ }
1631
+ maybeStart() {
1632
+ if (this.recording || !this.config || !this.clock || !this.transport) return;
1633
+ if (this.config.requireConsent && this.consent !== true) return;
1634
+ if (this.consent === false) return;
1635
+ if (!this.sampledIn) return;
1636
+ if (this.meta && this.meta.visitorId == null) {
1637
+ const visit = registerVisit(Date.now());
1638
+ this.meta.visitorId = visit.id;
1639
+ this.meta.visitCount = visit.visits;
1640
+ this.meta.firstSeenAt = visit.firstSeenAt;
1641
+ }
1642
+ const ctx = {
1643
+ clock: this.clock,
1644
+ sessionId: this.diag.sessionId,
1645
+ config: this.config,
1646
+ emit: (e) => {
1647
+ this.transport.enqueue(e);
1648
+ this.diag.eventsQueued++;
1649
+ },
1650
+ markActivity: () => {
1651
+ this.lastActivity = this.clock.now();
1652
+ },
1653
+ lastActivityT: () => this.lastActivity
1654
+ };
1655
+ this.transport.start();
1656
+ this.teardowns.push(instrumentConsole(ctx));
1657
+ this.teardowns.push(instrumentNetwork(ctx));
1658
+ this.teardowns.push(instrumentErrors(ctx));
1659
+ this.teardowns.push(instrumentVitals(ctx));
1660
+ this.teardowns.push(instrumentInteractions(ctx));
1661
+ this.teardowns.push(instrumentForms(ctx));
1662
+ this.teardowns.push(instrumentScrollDepth(ctx));
1663
+ this.teardowns.push(instrumentPageviews(ctx));
1664
+ this.teardowns.push(instrumentDom(ctx));
1665
+ this.recording = true;
1666
+ this.diag.recording = true;
1667
+ this.persistSession();
1668
+ if (this.config.showRecording && !this.recDotUnmount) {
1669
+ this.recDotUnmount = showRecordingDot(this.config.blockSelector);
1670
+ }
1671
+ }
1672
+ /** Associate the session with an application user. Stores the RAW userId (for
1673
+ * search + display) AND a SHA-256 identityHash, merges any traits into metadata,
1674
+ * and force-flushes so the live session is retro-stamped promptly. Safe to call
1675
+ * before recording starts (values ride the first batch) and after login. */
1676
+ identify(userId, traits) {
1677
+ if (!this.meta) return;
1678
+ const uid = String(userId);
1679
+ if (this.config?.continueSession && this.currentUserId != null && this.currentUserId !== uid) {
1680
+ this.reset();
1681
+ }
1682
+ this.currentUserId = uid;
1683
+ this.applyUserId(uid);
1684
+ if (traits) this.mergeMetadata(traits);
1685
+ this.track("identify", { hasTraits: !!traits });
1686
+ void this.flush();
1687
+ this.persistSession();
1688
+ }
1689
+ /** Merge custom properties onto the session at any time (searchable server-side). */
1690
+ setMetadata(props) {
1691
+ if (!this.meta) return;
1692
+ this.mergeMetadata(props);
1693
+ this.track("metadata", { keys: Object.keys(props) });
1694
+ void this.flush();
1695
+ }
1696
+ /** Forget the current identity (e.g. on logout) and rotate to a fresh anonymous
1697
+ * session, so post-logout activity isn't attributed to the prior user. (Identity
1698
+ * can't be cleared on the existing row — the server merge only fills nulls — so
1699
+ * we start a new session instead.) */
1700
+ reset() {
1701
+ if (!this.config) return;
1702
+ const wasRecording = this.recording;
1703
+ if (this.recording) this.teardown();
1704
+ clearSession();
1705
+ this.currentUserId = void 0;
1706
+ this.startSession(false);
1707
+ if (wasRecording && this.consent !== false && !privacySignalsOptOut()) this.maybeStart();
1708
+ }
1709
+ /** Set the raw userId on meta and compute its hash (async). */
1710
+ applyUserId(userId) {
1711
+ const raw = String(userId);
1712
+ if (this.meta) this.meta.userId = raw;
1713
+ void sha256Hex(raw).then((hash) => {
1714
+ if (this.meta && this.meta.userId === raw) this.meta.identityHash = hash;
1715
+ });
1716
+ }
1717
+ mergeMetadata(props) {
1718
+ if (!this.meta) return;
1719
+ this.meta.metadata = { ...this.meta.metadata ?? {}, ...props };
1720
+ }
1721
+ /** Emit a custom event (e.g. conversions). */
1722
+ track(name, props) {
1723
+ if (!this.recording || !this.clock || !this.transport) return;
1724
+ this.transport.enqueue({
1725
+ id: uuid(),
1726
+ sessionId: this.diag.sessionId,
1727
+ type: "custom",
1728
+ t: this.clock.now(),
1729
+ ts: this.clock.wall(),
1730
+ name,
1731
+ props
1732
+ });
1733
+ this.diag.eventsQueued++;
1734
+ }
1735
+ /** Mark a conversion. Conversion is client-defined, so the NAME matters —
1736
+ * "checkout", "signup", "trial_started" — and each is denormalised onto the
1737
+ * session for per-client filtering. Sets props.conversion so the legacy
1738
+ * 'conversion' flag still lights up. Sugar over track(name, {conversion:true}). */
1739
+ conversion(name, props) {
1740
+ const clean = String(name).trim();
1741
+ if (!clean) return;
1742
+ this.track(clean, { ...props, conversion: true });
1743
+ }
1744
+ /** Attach one or more free-form, searchable labels to the current session.
1745
+ * Tags are session-level (deduped server-side) — use them to slice sessions
1746
+ * however a client likes ("vip", "ab_variant_b", "mobile_app"). */
1747
+ tag(...names) {
1748
+ const clean = names.map((n) => String(n).trim()).filter(Boolean);
1749
+ if (!clean.length) return;
1750
+ this.track("_tag", { tags: clean });
1751
+ void this.flush();
1752
+ }
1753
+ /** Begin a named sub-event span. Pairs with end() / endTask(name) to produce a
1754
+ * searchable, duration-bearing span on the session timeline. Returns a handle
1755
+ * so callers can `const t = Obsrviq.startTask('checkout'); …; t.end()`. */
1756
+ startTask(name, props) {
1757
+ const clean = String(name).trim();
1758
+ const spanId = uuid();
1759
+ const startT = this.clock ? this.clock.now() : 0;
1760
+ if (clean) {
1761
+ this.openTasks.set(clean, { spanId, startT });
1762
+ this.track(`${clean}:started`, { ...props, spanId, task: clean });
1763
+ }
1764
+ return { end: (endProps) => this.endTask(clean, endProps) };
1765
+ }
1766
+ /** Close a named sub-event span opened by startTask(name). Safe to call even
1767
+ * if the start was never seen (renders as an orphan end on the timeline). */
1768
+ endTask(name, props) {
1769
+ const clean = String(name).trim();
1770
+ if (!clean) return;
1771
+ const open = this.openTasks.get(clean);
1772
+ this.openTasks.delete(clean);
1773
+ const spanId = open?.spanId ?? uuid();
1774
+ this.track(`${clean}:ended`, {
1775
+ ...props,
1776
+ spanId,
1777
+ task: clean,
1778
+ ...open ? { startT: open.startT } : {}
1779
+ });
1780
+ }
1781
+ /** Gate recording (PRIV-2). true starts; false stops + flushes. Dismisses the
1782
+ * built-in consent prompt if one is showing. */
1783
+ setConsent(granted) {
1784
+ this.consent = granted;
1785
+ this.consentUnmount?.();
1786
+ this.consentUnmount = null;
1787
+ if (granted) this.maybeStart();
1788
+ else if (this.recording) this.teardown();
1789
+ }
1790
+ /** Hard stop + flush. */
1791
+ stop() {
1792
+ this.teardown();
1793
+ }
1794
+ /** Force a flush now (returns when send is attempted). */
1795
+ flush() {
1796
+ return this.transport ? this.transport.flush("manual") : Promise.resolve(true);
1797
+ }
1798
+ getDiagnostics() {
1799
+ return { ...this.diag };
1800
+ }
1801
+ teardown() {
1802
+ for (const t of this.teardowns.splice(0)) {
1803
+ try {
1804
+ t();
1805
+ } catch {
1806
+ }
1807
+ }
1808
+ this.recDotUnmount?.();
1809
+ this.recDotUnmount = null;
1810
+ this.transport?.stop();
1811
+ this.recording = false;
1812
+ this.diag.recording = false;
1813
+ }
1814
+ };
1815
+ function stripUndefined(obj) {
1816
+ const out = {};
1817
+ for (const [k, v] of Object.entries(obj)) {
1818
+ if (v !== void 0) out[k] = v;
1819
+ }
1820
+ return out;
1821
+ }
1822
+ var Obsrviq = new ObsrviqClient();
1823
+ var src_default = Obsrviq;
1824
+
1825
+ export { Obsrviq, src_default as default };
1826
+ //# sourceMappingURL=index.js.map
1827
+ //# sourceMappingURL=index.js.map