@glassanalytics/browser 0.1.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,1022 @@
1
+ import { randomToken, prefixedId, evaluateFlag, INGEST_AUTH_SCHEME, replaySegmentPath } from '@glassanalytics/core';
2
+
3
+ // src/index.ts
4
+
5
+ // src/replay-encoder.ts
6
+ var WORKER_SRC = `
7
+ self.onmessage = async function (e) {
8
+ var id = e.data.id, json = e.data.json;
9
+ try {
10
+ var bytes = new TextEncoder().encode(json);
11
+ if (typeof CompressionStream !== 'undefined') {
12
+ var cs = new CompressionStream('gzip');
13
+ var w = cs.writable.getWriter();
14
+ w.write(bytes); w.close();
15
+ var buf = await new Response(cs.readable).arrayBuffer();
16
+ self.postMessage({ id: id, buf: buf, gzip: true }, [buf]);
17
+ } else {
18
+ self.postMessage({ id: id, buf: bytes.buffer, gzip: false }, [bytes.buffer]);
19
+ }
20
+ } catch (err) {
21
+ self.postMessage({ id: id, error: String(err) });
22
+ }
23
+ };
24
+ `;
25
+ var ReplayEncoder = class {
26
+ worker = null;
27
+ workerUrl = null;
28
+ workerBroken = false;
29
+ nextId = 1;
30
+ pending = /* @__PURE__ */ new Map();
31
+ /** Lazily spin up the encode worker; null if Workers/Blob URLs aren't available. */
32
+ ensureWorker() {
33
+ if (this.worker || this.workerBroken) return this.worker;
34
+ try {
35
+ if (typeof Worker === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined") {
36
+ this.workerBroken = true;
37
+ return null;
38
+ }
39
+ this.workerUrl = URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" }));
40
+ this.worker = new Worker(this.workerUrl);
41
+ this.worker.onmessage = (ev) => {
42
+ const resolve = this.pending.get(ev.data.id);
43
+ if (resolve) {
44
+ this.pending.delete(ev.data.id);
45
+ resolve(ev.data);
46
+ }
47
+ };
48
+ this.worker.onerror = () => {
49
+ this.workerBroken = true;
50
+ for (const [, resolve] of this.pending) resolve({ id: -1, error: "worker_error" });
51
+ this.pending.clear();
52
+ };
53
+ return this.worker;
54
+ } catch {
55
+ this.workerBroken = true;
56
+ return null;
57
+ }
58
+ }
59
+ async encode(segment) {
60
+ let json;
61
+ try {
62
+ json = JSON.stringify(segment);
63
+ } catch {
64
+ json = "[]";
65
+ }
66
+ const worker = this.ensureWorker();
67
+ if (worker) {
68
+ try {
69
+ const reply = await this.postToWorker(worker, json);
70
+ if (!reply.error && reply.buf) {
71
+ return { body: new Uint8Array(reply.buf), gzip: reply.gzip === true };
72
+ }
73
+ } catch {
74
+ }
75
+ }
76
+ return this.encodeMainThread(json);
77
+ }
78
+ postToWorker(worker, json) {
79
+ const id = this.nextId++;
80
+ return new Promise((resolve, reject) => {
81
+ const timeout = setTimeout(() => {
82
+ this.pending.delete(id);
83
+ reject(new Error("encode_timeout"));
84
+ }, 2e3);
85
+ this.pending.set(id, (r) => {
86
+ clearTimeout(timeout);
87
+ resolve(r);
88
+ });
89
+ worker.postMessage({ id, json });
90
+ });
91
+ }
92
+ /** Main-thread fallback: gzip via CompressionStream when present, else raw JSON. */
93
+ async encodeMainThread(json) {
94
+ const CS = globalThis.CompressionStream;
95
+ if (typeof CS !== "undefined") {
96
+ try {
97
+ const cs = new CS("gzip");
98
+ const writer = cs.writable.getWriter();
99
+ void writer.write(new TextEncoder().encode(json));
100
+ void writer.close();
101
+ const buf = await new Response(cs.readable).arrayBuffer();
102
+ return { body: new Uint8Array(buf), gzip: true };
103
+ } catch {
104
+ }
105
+ }
106
+ return { body: json, gzip: false };
107
+ }
108
+ dispose() {
109
+ this.worker?.terminate();
110
+ this.worker = null;
111
+ if (this.workerUrl) {
112
+ try {
113
+ URL.revokeObjectURL(this.workerUrl);
114
+ } catch {
115
+ }
116
+ this.workerUrl = null;
117
+ }
118
+ this.pending.clear();
119
+ }
120
+ };
121
+
122
+ // src/replay.ts
123
+ var SENSITIVE_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "email", "tel", "hidden"]);
124
+ var SENSITIVE_NAME_RE = /pass|secret|token|ssn|card|cvv|cvc|account|email|phone|tax/i;
125
+ var ReplayRecorder = class {
126
+ constructor(cfg, getSessionId) {
127
+ this.cfg = cfg;
128
+ this.getSessionId = getSessionId;
129
+ }
130
+ cfg;
131
+ getSessionId;
132
+ stop = null;
133
+ seq = 0;
134
+ events = [];
135
+ interval = null;
136
+ boundFinal = null;
137
+ encoder = new ReplayEncoder();
138
+ async start() {
139
+ if (this.stop) return;
140
+ if (typeof window === "undefined") return;
141
+ const { record } = await import('rrweb');
142
+ this.stop = record({
143
+ emit: (event) => {
144
+ this.events.push(event);
145
+ if (this.events.length >= 50) void this.flushSegment();
146
+ },
147
+ // Privacy defaults: never leak user input.
148
+ maskAllInputs: this.cfg.maskAllInputs,
149
+ maskTextSelector: this.cfg.maskTextSelector,
150
+ blockSelector: this.cfg.blockSelector,
151
+ // ALWAYS mask sensitive fields regardless of maskAllInputs (defense in depth).
152
+ maskInputFn: (text, element) => {
153
+ if (this.cfg.maskAllInputs) return "*".repeat(text.length);
154
+ const type = (element.getAttribute("type") ?? "").toLowerCase();
155
+ const name = `${element.getAttribute("name") ?? ""} ${element.getAttribute("autocomplete") ?? ""} ${element.id}`;
156
+ if (SENSITIVE_INPUT_TYPES.has(type) || SENSITIVE_NAME_RE.test(name)) return "*".repeat(text.length);
157
+ return text;
158
+ },
159
+ recordCanvas: false,
160
+ collectFonts: false
161
+ }) ?? null;
162
+ this.interval = setInterval(() => void this.flushSegment(), 5e3);
163
+ this.boundFinal = () => this.flushFinalBeacon();
164
+ window.addEventListener("pagehide", this.boundFinal);
165
+ }
166
+ async flushSegment() {
167
+ if (this.events.length === 0) return;
168
+ const segment = this.events.splice(0, this.events.length);
169
+ const seq = this.seq++;
170
+ const url = `${this.cfg.ingestHost}${replaySegmentPath(this.getSessionId(), seq)}`;
171
+ const encoded = await this.encoder.encode(segment);
172
+ const headers = {
173
+ "Content-Type": "application/json",
174
+ Authorization: `Glass-Key ${this.cfg.projectKey}`
175
+ };
176
+ if (encoded.gzip) headers["Content-Encoding"] = "gzip";
177
+ try {
178
+ await fetch(url, {
179
+ method: "POST",
180
+ keepalive: true,
181
+ headers,
182
+ body: encoded.body
183
+ });
184
+ } catch {
185
+ }
186
+ }
187
+ /** Flush the trailing segment with sendBeacon so it survives page teardown. */
188
+ flushFinalBeacon() {
189
+ if (this.events.length === 0) return;
190
+ const segment = this.events.splice(0, this.events.length);
191
+ const seq = this.seq++;
192
+ const url = `${this.cfg.ingestHost}${replaySegmentPath(this.getSessionId(), seq)}?k=${encodeURIComponent(this.cfg.projectKey)}`;
193
+ const payload = JSON.stringify(segment);
194
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
195
+ navigator.sendBeacon(url, payload);
196
+ }
197
+ }
198
+ async stopRecording() {
199
+ await this.flushSegment();
200
+ this.stop?.();
201
+ this.stop = null;
202
+ if (this.interval) {
203
+ clearInterval(this.interval);
204
+ this.interval = null;
205
+ }
206
+ if (this.boundFinal && typeof window !== "undefined") {
207
+ window.removeEventListener("pagehide", this.boundFinal);
208
+ this.boundFinal = null;
209
+ }
210
+ this.encoder.dispose();
211
+ }
212
+ get recording() {
213
+ return this.stop !== null;
214
+ }
215
+ };
216
+
217
+ // src/offline-store.ts
218
+ var DB_NAME = "glass";
219
+ var STORE = "offline_q";
220
+ var LS_KEY = "glass_offline_q";
221
+ var OfflineStore = class {
222
+ constructor(max, ttlMs) {
223
+ this.max = max;
224
+ this.ttlMs = ttlMs;
225
+ }
226
+ max;
227
+ ttlMs;
228
+ mem = [];
229
+ dbPromise = null;
230
+ idb() {
231
+ if (typeof indexedDB === "undefined") return null;
232
+ if (!this.dbPromise) {
233
+ this.dbPromise = new Promise((resolve, reject) => {
234
+ const req = indexedDB.open(DB_NAME, 1);
235
+ req.onupgradeneeded = () => {
236
+ const db = req.result;
237
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { autoIncrement: true });
238
+ };
239
+ req.onsuccess = () => resolve(req.result);
240
+ req.onerror = () => reject(req.error);
241
+ }).catch((err) => {
242
+ this.dbPromise = null;
243
+ throw err;
244
+ });
245
+ }
246
+ return this.dbPromise;
247
+ }
248
+ fresh(items) {
249
+ const cutoff = Date.now() - this.ttlMs;
250
+ const kept = items.filter((b) => b.at >= cutoff);
251
+ return kept.length > this.max ? kept.slice(kept.length - this.max) : kept;
252
+ }
253
+ async append(batch) {
254
+ try {
255
+ const db = await this.idb();
256
+ if (db) {
257
+ await this.idbWrite(db, batch);
258
+ return;
259
+ }
260
+ } catch {
261
+ }
262
+ if (this.tryLS((q) => this.fresh([...q, batch]))) return;
263
+ this.mem = this.fresh([...this.mem, batch]);
264
+ }
265
+ async drain() {
266
+ try {
267
+ const db = await this.idb();
268
+ if (db) return this.fresh(await this.idbDrain(db));
269
+ } catch {
270
+ }
271
+ const ls2 = this.readLS();
272
+ if (ls2 !== null) {
273
+ this.writeLS([]);
274
+ return this.fresh(ls2);
275
+ }
276
+ const m = this.mem;
277
+ this.mem = [];
278
+ return this.fresh(m);
279
+ }
280
+ async clear() {
281
+ this.mem = [];
282
+ try {
283
+ const db = await this.idb();
284
+ if (db) {
285
+ await new Promise((resolve) => {
286
+ const tx = db.transaction(STORE, "readwrite");
287
+ tx.objectStore(STORE).clear();
288
+ tx.oncomplete = () => resolve();
289
+ tx.onerror = () => resolve();
290
+ });
291
+ }
292
+ } catch {
293
+ }
294
+ this.writeLS([]);
295
+ }
296
+ // --- IndexedDB helpers ------------------------------------------------------
297
+ idbWrite(db, batch) {
298
+ return new Promise((resolve, reject) => {
299
+ const tx = db.transaction(STORE, "readwrite");
300
+ tx.objectStore(STORE).add(batch);
301
+ tx.oncomplete = () => resolve();
302
+ tx.onerror = () => reject(tx.error);
303
+ });
304
+ }
305
+ idbDrain(db) {
306
+ return new Promise((resolve, reject) => {
307
+ const tx = db.transaction(STORE, "readwrite");
308
+ const store = tx.objectStore(STORE);
309
+ const getAll = store.getAll();
310
+ getAll.onsuccess = () => {
311
+ store.clear();
312
+ resolve(getAll.result ?? []);
313
+ };
314
+ getAll.onerror = () => reject(getAll.error);
315
+ });
316
+ }
317
+ // --- localStorage fallback --------------------------------------------------
318
+ readLS() {
319
+ if (typeof localStorage === "undefined") return null;
320
+ try {
321
+ const raw = localStorage.getItem(LS_KEY);
322
+ return raw ? JSON.parse(raw) : [];
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+ writeLS(q) {
328
+ if (typeof localStorage === "undefined") return;
329
+ try {
330
+ localStorage.setItem(LS_KEY, JSON.stringify(q));
331
+ } catch {
332
+ }
333
+ }
334
+ tryLS(update) {
335
+ const cur = this.readLS();
336
+ if (cur === null) return false;
337
+ this.writeLS(update(cur));
338
+ return true;
339
+ }
340
+ };
341
+
342
+ // src/transport.ts
343
+ var QUEUE_TTL_MS = 24 * 36e5;
344
+ var QUEUE_MAX = 500;
345
+ var BUFFER_MAX = 1e3;
346
+ var BACKOFF_BASE_MS = 1e3;
347
+ var BACKOFF_CAP_MS = 5 * 6e4;
348
+ function eventValue(e) {
349
+ switch (e.type) {
350
+ case "error":
351
+ return 100;
352
+ case "identify":
353
+ case "group":
354
+ return 90;
355
+ case "exposure":
356
+ return 80;
357
+ case "track":
358
+ return e.event === "$autocapture" || e.event === "$pageview" ? 10 : 50;
359
+ default:
360
+ return 40;
361
+ }
362
+ }
363
+ var Transport = class {
364
+ constructor(cfg, getIdentity) {
365
+ this.cfg = cfg;
366
+ this.getIdentity = getIdentity;
367
+ if (typeof window !== "undefined") {
368
+ window.addEventListener("pagehide", this.onPageHide);
369
+ window.addEventListener("online", this.onOnline);
370
+ }
371
+ }
372
+ cfg;
373
+ getIdentity;
374
+ buffer = [];
375
+ timer = null;
376
+ retryTimer = null;
377
+ backoffUntil = 0;
378
+ failures = 0;
379
+ store = new OfflineStore(QUEUE_MAX, QUEUE_TTL_MS);
380
+ onPageHide = () => void this.flush(true);
381
+ onOnline = () => void this.drainOffline();
382
+ /** Remove window listeners and pending timers (called from Glass.shutdown()). */
383
+ destroy() {
384
+ if (typeof window !== "undefined") {
385
+ window.removeEventListener("pagehide", this.onPageHide);
386
+ window.removeEventListener("online", this.onOnline);
387
+ }
388
+ if (this.timer) {
389
+ clearTimeout(this.timer);
390
+ this.timer = null;
391
+ }
392
+ if (this.retryTimer) {
393
+ clearTimeout(this.retryTimer);
394
+ this.retryTimer = null;
395
+ }
396
+ }
397
+ enqueue(event) {
398
+ const finalEvent = this.cfg.beforeSend(event);
399
+ if (!finalEvent) return;
400
+ this.buffer.push(finalEvent);
401
+ this.applyBackpressure();
402
+ if (this.buffer.length >= this.cfg.flushAt) {
403
+ void this.flush();
404
+ } else if (!this.timer) {
405
+ this.timer = setTimeout(() => void this.flush(), this.cfg.flushIntervalMs);
406
+ }
407
+ }
408
+ /**
409
+ * Cap the in-memory buffer by shedding the lowest-value events when it grows
410
+ * past BUFFER_MAX (e.g. a long offline burst of autocapture). Errors and
411
+ * identity events are kept preferentially.
412
+ */
413
+ applyBackpressure() {
414
+ if (this.buffer.length <= BUFFER_MAX) return;
415
+ const indexed = this.buffer.map((e, i) => ({ e, i }));
416
+ indexed.sort((a, b) => eventValue(b.e) - eventValue(a.e) || a.i - b.i);
417
+ const kept = indexed.slice(0, BUFFER_MAX).sort((a, b) => a.i - b.i);
418
+ this.buffer = kept.map((x) => x.e);
419
+ }
420
+ async flush(useBeacon = false) {
421
+ if (this.timer) {
422
+ clearTimeout(this.timer);
423
+ this.timer = null;
424
+ }
425
+ if (this.buffer.length === 0) return;
426
+ const events = this.buffer.splice(0, this.buffer.length);
427
+ const body = {
428
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
429
+ ...this.getIdentity(),
430
+ events
431
+ };
432
+ if (Date.now() < this.backoffUntil) {
433
+ await this.store.append({ at: Date.now(), body });
434
+ this.scheduleRetry();
435
+ return;
436
+ }
437
+ await this.send(body, useBeacon);
438
+ }
439
+ async send(body, useBeacon) {
440
+ const url = `${this.cfg.ingestHost}/v1/batch`;
441
+ const payload = JSON.stringify(body);
442
+ if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
443
+ navigator.sendBeacon(`${url}?k=${encodeURIComponent(this.cfg.projectKey)}`, payload);
444
+ return;
445
+ }
446
+ try {
447
+ const res = await fetch(url, {
448
+ method: "POST",
449
+ keepalive: true,
450
+ headers: {
451
+ "Content-Type": "application/json",
452
+ Authorization: `${INGEST_AUTH_SCHEME} ${this.cfg.projectKey}`
453
+ },
454
+ body: payload
455
+ });
456
+ if (res.status === 429) {
457
+ const retry = Number(res.headers.get("retry-after") ?? "0");
458
+ this.failures++;
459
+ this.backoffUntil = Date.now() + (retry > 0 ? retry * 1e3 : this.backoffMs());
460
+ await this.store.append({ at: Date.now(), body });
461
+ this.scheduleRetry();
462
+ } else if (res.status >= 500) {
463
+ this.failures++;
464
+ this.backoffUntil = Date.now() + this.backoffMs();
465
+ await this.store.append({ at: Date.now(), body });
466
+ this.scheduleRetry();
467
+ } else {
468
+ this.failures = 0;
469
+ }
470
+ } catch {
471
+ this.failures++;
472
+ this.backoffUntil = Date.now() + this.backoffMs();
473
+ await this.store.append({ at: Date.now(), body });
474
+ this.scheduleRetry();
475
+ }
476
+ }
477
+ /** Exponential backoff with full jitter, capped. */
478
+ backoffMs() {
479
+ const exp = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.min(this.failures, 12));
480
+ return Math.floor(Math.random() * exp);
481
+ }
482
+ scheduleRetry() {
483
+ if (this.retryTimer) return;
484
+ const delay = Math.max(0, this.backoffUntil - Date.now()) + 250;
485
+ this.retryTimer = setTimeout(() => {
486
+ this.retryTimer = null;
487
+ void this.drainOffline();
488
+ }, delay);
489
+ }
490
+ // --- durable offline queue (masked data only; TTL'd; size-capped) ----------
491
+ async drainOffline() {
492
+ if (Date.now() < this.backoffUntil) {
493
+ this.scheduleRetry();
494
+ return;
495
+ }
496
+ const q = await this.store.drain();
497
+ for (const item of q) {
498
+ if (Date.now() < this.backoffUntil) {
499
+ await this.store.append(item);
500
+ this.scheduleRetry();
501
+ return;
502
+ }
503
+ await this.send(item.body, false);
504
+ }
505
+ }
506
+ /** optOut/reset clears any queued (but already-masked) payloads. */
507
+ clearOffline() {
508
+ void this.store.clear();
509
+ }
510
+ /** Drop the in-memory buffer so no buffered PII survives optOut()/reset(). */
511
+ clearBuffer() {
512
+ this.buffer = [];
513
+ if (this.timer) {
514
+ clearTimeout(this.timer);
515
+ this.timer = null;
516
+ }
517
+ }
518
+ };
519
+
520
+ // src/index.ts
521
+ var SESSION_IDLE_MS = 30 * 6e4;
522
+ var DEVICE_KEY = "glass_device_id";
523
+ var SESSION_KEY = "glass_session";
524
+ function ls() {
525
+ try {
526
+ return typeof localStorage !== "undefined" ? localStorage : null;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+ var Glass = class {
532
+ cfg;
533
+ transport;
534
+ replay;
535
+ deviceId = "";
536
+ sessionId = "";
537
+ userId = null;
538
+ traits = {};
539
+ groupCtx = null;
540
+ superProps = {};
541
+ seq = 0;
542
+ lastActivity = 0;
543
+ flags = {};
544
+ flagsLoaded = false;
545
+ flagListeners = [];
546
+ optedOut = false;
547
+ started = false;
548
+ /** Calls made before init() are queued and replayed once started (`SDK.md` §2). */
549
+ preInitQueue = [];
550
+ /** Removers for every listener/patch we install, so shutdown() leaves no trace. */
551
+ teardowns = [];
552
+ init(config) {
553
+ if (this.started) return;
554
+ this.cfg = {
555
+ projectKey: config.projectKey,
556
+ ingestHost: config.ingestHost ?? "https://in.glass.dev",
557
+ apiHost: config.apiHost ?? "https://api.glass.dev",
558
+ replay: config.replay ?? true,
559
+ autocapture: config.autocapture ?? true,
560
+ errors: config.errors ?? true,
561
+ captureConsole: config.captureConsole ?? false,
562
+ sampleRate: config.sampleRate ?? 1,
563
+ consent: config.consent ?? "implied",
564
+ maskAllInputs: config.maskAllInputs ?? true,
565
+ maskTextSelector: config.maskTextSelector,
566
+ blockSelector: config.blockSelector,
567
+ respectDoNotTrack: config.respectDoNotTrack ?? true,
568
+ flushIntervalMs: config.flushIntervalMs ?? 5e3,
569
+ flushAt: config.flushAt ?? 25,
570
+ beforeSend: config.beforeSend ?? ((e) => e),
571
+ bootstrapFlags: config.bootstrapFlags ?? {},
572
+ flagDefinitions: config.flagDefinitions ?? []
573
+ };
574
+ this.flags = { ...this.cfg.bootstrapFlags };
575
+ if (this.cfg.respectDoNotTrack && typeof navigator !== "undefined" && navigator.doNotTrack === "1") {
576
+ this.optedOut = true;
577
+ }
578
+ this.deviceId = this.loadDeviceId();
579
+ this.sessionId = this.loadSession();
580
+ this.transport = new Transport(this.cfg, () => ({
581
+ device_id: this.deviceId,
582
+ session_id: this.sessionId,
583
+ ...this.userId ? { user_id: this.userId } : {},
584
+ traits: this.traits,
585
+ ...this.groupCtx ? { group: this.groupCtx } : {}
586
+ }));
587
+ this.replay = new ReplayRecorder(this.cfg, () => this.sessionId);
588
+ this.started = true;
589
+ if (this.optedOut || this.cfg.consent === "opt-in") ; else {
590
+ this.afterConsent();
591
+ }
592
+ void this.reloadFeatureFlags();
593
+ const queued = this.preInitQueue.splice(0, this.preInitQueue.length);
594
+ for (const fn of queued) fn();
595
+ }
596
+ afterConsent() {
597
+ if (this.cfg.errors) this.installErrorHandlers();
598
+ if (this.cfg.autocapture) this.installAutocapture();
599
+ if (this.cfg.captureConsole) this.installConsoleCapture();
600
+ this.installVisibilityFlush();
601
+ if (this.cfg.replay && Math.random() < this.cfg.sampleRate) void this.replay.start();
602
+ void this.transport.drainOffline();
603
+ this.track("$pageview", this.pageProps());
604
+ }
605
+ /** Register a DOM listener and remember how to remove it on shutdown(). */
606
+ addListener(target, type, handler, opts) {
607
+ target.addEventListener(type, handler, opts);
608
+ this.teardowns.push(() => target.removeEventListener(type, handler, opts));
609
+ }
610
+ /**
611
+ * Flush on tab hide (`SDK.md` §10). `visibilitychange`→hidden is the most
612
+ * reliable "user is leaving" signal on mobile (where `pagehide`/`unload` are
613
+ * unreliable), so we drain the buffer with a beacon while we still can.
614
+ */
615
+ installVisibilityFlush() {
616
+ if (typeof document === "undefined") return;
617
+ this.addListener(document, "visibilitychange", () => {
618
+ if (document.visibilityState === "hidden") void this.transport.flush(true);
619
+ });
620
+ }
621
+ // --- identity --------------------------------------------------------------
622
+ loadDeviceId() {
623
+ const store = ls();
624
+ let id = store?.getItem(DEVICE_KEY) ?? null;
625
+ if (!id) {
626
+ id = `dev_${randomToken(16)}`;
627
+ store?.setItem(DEVICE_KEY, id);
628
+ }
629
+ return id;
630
+ }
631
+ loadSession() {
632
+ const store = ls();
633
+ try {
634
+ const raw = store?.getItem(SESSION_KEY);
635
+ if (raw) {
636
+ const parsed = JSON.parse(raw);
637
+ if (Date.now() - parsed.last < SESSION_IDLE_MS) {
638
+ this.lastActivity = parsed.last;
639
+ return parsed.id;
640
+ }
641
+ }
642
+ } catch {
643
+ }
644
+ const id = prefixedId("sess");
645
+ this.persistSession(id);
646
+ return id;
647
+ }
648
+ persistSession(id) {
649
+ this.lastActivity = Date.now();
650
+ ls()?.setItem(SESSION_KEY, JSON.stringify({ id, last: this.lastActivity }));
651
+ }
652
+ touchSession() {
653
+ if (Date.now() - this.lastActivity > SESSION_IDLE_MS) {
654
+ this.sessionId = prefixedId("sess");
655
+ this.seq = 0;
656
+ }
657
+ this.persistSession(this.sessionId);
658
+ }
659
+ /** Queue a call made before init() so nothing is lost; returns true if deferred. */
660
+ deferred(fn) {
661
+ if (this.started) return false;
662
+ this.preInitQueue.push(fn);
663
+ return true;
664
+ }
665
+ identify(userId, traits = {}) {
666
+ if (this.deferred(() => this.identify(userId, traits))) return;
667
+ this.userId = userId;
668
+ this.traits = { ...this.traits, ...traits };
669
+ this.emit("identify", void 0, traits);
670
+ void this.reloadFeatureFlags();
671
+ }
672
+ reset() {
673
+ void this.transport.flush();
674
+ this.transport.clearBuffer();
675
+ this.transport.clearOffline();
676
+ this.exposedKeys.clear();
677
+ this.flags = { ...this.cfg.bootstrapFlags };
678
+ this.userId = null;
679
+ this.traits = {};
680
+ this.groupCtx = null;
681
+ this.superProps = {};
682
+ const store = ls();
683
+ store?.removeItem(SESSION_KEY);
684
+ this.deviceId = `dev_${randomToken(16)}`;
685
+ store?.setItem(DEVICE_KEY, this.deviceId);
686
+ this.sessionId = prefixedId("sess");
687
+ this.seq = 0;
688
+ }
689
+ getDeviceId() {
690
+ return this.deviceId;
691
+ }
692
+ getSessionId() {
693
+ return this.sessionId;
694
+ }
695
+ getSessionReplayUrl() {
696
+ return `${this.cfg.apiHost}/replays/${this.sessionId}`;
697
+ }
698
+ groupIdentify(type, key, traits = {}) {
699
+ if (this.deferred(() => this.groupIdentify(type, key, traits))) return;
700
+ this.groupCtx = { type, key };
701
+ this.emit("group", void 0, { group_type: type, group_key: key, ...traits });
702
+ }
703
+ /** Alias for `groupIdentify` (`SDK.md` API parity). */
704
+ group(type, key, traits = {}) {
705
+ this.groupIdentify(type, key, traits);
706
+ }
707
+ // --- events ----------------------------------------------------------------
708
+ register(props) {
709
+ if (this.deferred(() => this.register(props))) return;
710
+ this.superProps = { ...this.superProps, ...props };
711
+ }
712
+ unregister(key) {
713
+ if (this.deferred(() => this.unregister(key))) return;
714
+ delete this.superProps[key];
715
+ }
716
+ track(event, props = {}) {
717
+ if (this.deferred(() => this.track(event, props))) return;
718
+ this.emit("track", event, props);
719
+ }
720
+ emit(type, event, props) {
721
+ if (this.optedOut || !this.started) return;
722
+ this.touchSession();
723
+ this.transport.enqueue({
724
+ type,
725
+ ...event ? { event } : {},
726
+ seq: this.seq++,
727
+ t_client: (/* @__PURE__ */ new Date()).toISOString(),
728
+ props: { ...this.contextProps(), ...this.superProps, ...props }
729
+ });
730
+ }
731
+ /** Device/screen context attached to EVERY event (`SDK.md` §4). */
732
+ contextProps() {
733
+ const ctx = {};
734
+ if (typeof screen !== "undefined") {
735
+ ctx.$screen_width = screen.width;
736
+ ctx.$screen_height = screen.height;
737
+ }
738
+ if (typeof window !== "undefined") {
739
+ ctx.$viewport_width = window.innerWidth;
740
+ ctx.$viewport_height = window.innerHeight;
741
+ }
742
+ if (typeof navigator !== "undefined") {
743
+ ctx.$locale = navigator.language ?? null;
744
+ }
745
+ return ctx;
746
+ }
747
+ pageProps() {
748
+ if (typeof document === "undefined" || typeof location === "undefined") return {};
749
+ return { url: location.href, $pathname: location.pathname, referrer: document.referrer };
750
+ }
751
+ // --- errors ----------------------------------------------------------------
752
+ captureException(error, context = {}) {
753
+ if (this.deferred(() => this.captureException(error, context))) return;
754
+ const err = error instanceof Error ? error : new Error(String(error));
755
+ this.emit("error", err.name, {
756
+ message: err.message,
757
+ stack: err.stack ?? "",
758
+ ...context
759
+ });
760
+ }
761
+ /** Breadcrumb with optional category (`SDK.md` API parity). */
762
+ addBreadcrumb(arg, data = {}) {
763
+ if (this.deferred(() => this.addBreadcrumb(arg, data))) return;
764
+ if (typeof arg === "string") {
765
+ this.emit("track", "$breadcrumb", { message: arg, ...data });
766
+ } else {
767
+ this.emit("track", "$breadcrumb", {
768
+ message: arg.message,
769
+ ...arg.category ? { category: arg.category } : {},
770
+ ...arg.data ?? {}
771
+ });
772
+ }
773
+ }
774
+ installErrorHandlers() {
775
+ if (typeof window === "undefined") return;
776
+ this.addListener(
777
+ window,
778
+ "error",
779
+ (e) => this.captureException(e.error ?? e.message)
780
+ );
781
+ this.addListener(
782
+ window,
783
+ "unhandledrejection",
784
+ (e) => this.captureException(e.reason)
785
+ );
786
+ }
787
+ /**
788
+ * Opt-in console capture (`SDK.md` §5): mirror `console.error`/`console.warn`
789
+ * into Glass (errors as exceptions, warnings as breadcrumbs) while ALWAYS
790
+ * calling through to the original console, so we never swallow the developer's
791
+ * own logs. Restored verbatim on shutdown().
792
+ */
793
+ installConsoleCapture() {
794
+ if (typeof console === "undefined") return;
795
+ const wrap = (level) => {
796
+ const original = console[level];
797
+ if (typeof original !== "function") return;
798
+ console[level] = (...args) => {
799
+ try {
800
+ const first = args[0];
801
+ const message = args.map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : safeString(a)).join(" ").slice(0, 500);
802
+ if (level === "error") {
803
+ this.captureException(first instanceof Error ? first : new Error(message), { $console: true });
804
+ } else {
805
+ this.addBreadcrumb({ category: "console.warn", message });
806
+ }
807
+ } catch {
808
+ }
809
+ return original.apply(console, args);
810
+ };
811
+ this.teardowns.push(() => {
812
+ console[level] = original;
813
+ });
814
+ };
815
+ wrap("error");
816
+ wrap("warn");
817
+ }
818
+ rageWindow = [];
819
+ installAutocapture() {
820
+ if (typeof document === "undefined") return;
821
+ this.addListener(
822
+ document,
823
+ "click",
824
+ (e) => {
825
+ const target = e.target;
826
+ if (!target) return;
827
+ this.detectRageClick();
828
+ const el = target.closest("a,button,input[type=submit],[role=button],[data-glass]");
829
+ if (!el) return;
830
+ this.track("$click", this.elementProps(el));
831
+ },
832
+ { capture: true }
833
+ );
834
+ this.addListener(
835
+ document,
836
+ "submit",
837
+ (e) => {
838
+ const form = e.target;
839
+ if (!form || form.tagName !== "FORM") return;
840
+ this.track("$form_submit", {
841
+ form_id: form.id || null,
842
+ form_name: form.getAttribute("name"),
843
+ action: form.getAttribute("action"),
844
+ field_count: form.elements.length
845
+ });
846
+ },
847
+ { capture: true }
848
+ );
849
+ this.installSpaPageviews();
850
+ }
851
+ /** Stable, value-free descriptor of a clicked element. */
852
+ elementProps(el) {
853
+ return {
854
+ tag: el.tagName.toLowerCase(),
855
+ text: (el.textContent ?? "").trim().slice(0, 80),
856
+ href: el instanceof HTMLAnchorElement ? el.href : null,
857
+ el_id: el.id || null,
858
+ el_class: el.getAttribute("class"),
859
+ data_glass: el.getAttribute("data-glass")
860
+ };
861
+ }
862
+ /** Three+ clicks within 1s at roughly one spot = a rage click (frustration signal). */
863
+ detectRageClick() {
864
+ const now = Date.now();
865
+ this.rageWindow = this.rageWindow.filter((t) => now - t < 1e3);
866
+ this.rageWindow.push(now);
867
+ if (this.rageWindow.length >= 3) {
868
+ this.rageWindow = [];
869
+ this.track("$rageclick", { url: typeof location !== "undefined" ? location.href : null });
870
+ }
871
+ }
872
+ /** SPA route changes: patch History + listen to popstate, de-duping same-URL. */
873
+ lastPath = "";
874
+ installSpaPageviews() {
875
+ if (typeof history === "undefined" || typeof location === "undefined") return;
876
+ this.lastPath = location.href;
877
+ const fire = () => {
878
+ if (location.href === this.lastPath) return;
879
+ this.lastPath = location.href;
880
+ this.track("$pageview", this.pageProps());
881
+ };
882
+ const wrap = (key) => {
883
+ const orig = history[key];
884
+ history[key] = function patched(...args) {
885
+ const ret = orig.apply(this, args);
886
+ setTimeout(fire, 0);
887
+ return ret;
888
+ };
889
+ this.teardowns.push(() => {
890
+ history[key] = orig;
891
+ });
892
+ };
893
+ wrap("pushState");
894
+ wrap("replaceState");
895
+ this.addListener(window, "popstate", fire);
896
+ }
897
+ // --- session replay control ------------------------------------------------
898
+ startSessionRecording() {
899
+ void this.replay.start();
900
+ }
901
+ stopSessionRecording() {
902
+ void this.replay.stopRecording();
903
+ }
904
+ // --- feature flags (deterministic, edge-evaluated) -------------------------
905
+ async reloadFeatureFlags() {
906
+ try {
907
+ const unit = this.userId ?? this.deviceId;
908
+ const res = await fetch(`${this.cfg.apiHost}/v1/sdk/flags?user_id=${encodeURIComponent(unit)}`, {
909
+ headers: { Authorization: `Glass-Key ${this.cfg.projectKey}` }
910
+ });
911
+ if (res.ok) {
912
+ const json = await res.json();
913
+ this.flags = { ...this.flags, ...json.flags ?? {} };
914
+ }
915
+ } catch {
916
+ } finally {
917
+ this.flagsLoaded = true;
918
+ for (const fn of this.flagListeners) fn(this.flags);
919
+ }
920
+ }
921
+ onFlagsLoaded(fn) {
922
+ this.flagListeners.push(fn);
923
+ if (this.flagsLoaded) fn(this.flags);
924
+ }
925
+ /**
926
+ * Resolve a flag value with precedence: locally-evaluated definition (if the
927
+ * app shipped one, so targeting reflects CURRENT properties) → server/bootstrap
928
+ * resolved value → fallback.
929
+ */
930
+ resolve(key, opts) {
931
+ const local = this.evaluateLocal(key);
932
+ const v = local !== void 0 ? local : this.flags[key];
933
+ const value = v !== void 0 ? v : opts?.fallback;
934
+ if (value !== void 0 && opts?.trackExposure !== false) this.exposure(key, value);
935
+ return value;
936
+ }
937
+ /** Local bucketing via @glassanalytics/core when definitions were provided to init(). */
938
+ evaluateLocal(key) {
939
+ const def = this.cfg.flagDefinitions.find((d) => d.key === key);
940
+ if (!def) return void 0;
941
+ const properties = {};
942
+ for (const [k, val] of Object.entries(this.traits)) {
943
+ if (val === null || typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
944
+ properties[k] = val;
945
+ }
946
+ }
947
+ return evaluateFlag(def, { unitId: this.userId ?? this.deviceId, properties });
948
+ }
949
+ isFeatureEnabledSync(key, opts) {
950
+ const v = this.resolve(key, opts);
951
+ return v === true || typeof v === "string" && v !== "";
952
+ }
953
+ async isFeatureEnabled(key, opts) {
954
+ if (!this.flagsLoaded) await this.reloadFeatureFlags();
955
+ return this.isFeatureEnabledSync(key, opts);
956
+ }
957
+ getFeatureFlagSync(key, opts) {
958
+ return this.resolve(key, opts);
959
+ }
960
+ async getFeatureFlag(key, opts) {
961
+ if (!this.flagsLoaded) await this.reloadFeatureFlags();
962
+ return this.getFeatureFlagSync(key, opts);
963
+ }
964
+ getVariantSync(key, opts) {
965
+ return this.resolve(key, opts);
966
+ }
967
+ async getVariant(key, opts) {
968
+ if (!this.flagsLoaded) await this.reloadFeatureFlags();
969
+ return this.getVariantSync(key, opts);
970
+ }
971
+ /** Fire a `$exposure` once per (flag, value) so experiment analysis is honest (§8). */
972
+ exposedKeys = /* @__PURE__ */ new Set();
973
+ exposure(key, value) {
974
+ const k = `${key}=${String(value)}`;
975
+ if (this.exposedKeys.has(k)) return;
976
+ this.exposedKeys.add(k);
977
+ this.emit("exposure", key, { $feature_flag: key, $feature_flag_value: String(value) });
978
+ }
979
+ // --- consent ---------------------------------------------------------------
980
+ optIn() {
981
+ this.optedOut = false;
982
+ this.afterConsent();
983
+ }
984
+ optOut() {
985
+ this.optedOut = true;
986
+ this.transport.clearBuffer();
987
+ this.transport.clearOffline();
988
+ void this.replay.stopRecording();
989
+ }
990
+ hasConsent() {
991
+ return !this.optedOut;
992
+ }
993
+ // --- lifecycle -------------------------------------------------------------
994
+ flush() {
995
+ return this.transport.flush();
996
+ }
997
+ shutdown() {
998
+ void this.transport.flush(true);
999
+ void this.replay.stopRecording();
1000
+ for (const off of this.teardowns.splice(0, this.teardowns.length).reverse()) {
1001
+ try {
1002
+ off();
1003
+ } catch {
1004
+ }
1005
+ }
1006
+ this.transport.destroy();
1007
+ this.started = false;
1008
+ }
1009
+ };
1010
+ function safeString(value) {
1011
+ try {
1012
+ return JSON.stringify(value) ?? String(value);
1013
+ } catch {
1014
+ return String(value);
1015
+ }
1016
+ }
1017
+ var glass = new Glass();
1018
+ var index_default = glass;
1019
+
1020
+ export { Glass, index_default as default, glass };
1021
+ //# sourceMappingURL=index.js.map
1022
+ //# sourceMappingURL=index.js.map