@guidelytech/sdk-core 0.2.0 → 0.4.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 CHANGED
@@ -1,9 +1,23 @@
1
- var __defProp = Object.defineProperty;
2
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
- var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
1
+ import {
2
+ Emitter,
3
+ EventQueue,
4
+ KEYS,
5
+ areMutuallyExclusive,
6
+ createStorage,
7
+ isExclusiveGuide,
8
+ isFrequencyEligible,
9
+ isWithinSchedule,
10
+ readJson,
11
+ writeJson
12
+ } from "./chunk-HLR3W63Z.js";
13
+ import {
14
+ Api,
15
+ __publicField,
16
+ postWithRetry
17
+ } from "./chunk-GJT7CFAC.js";
4
18
 
5
19
  // src/client.ts
6
- import { evaluateRules, randomId, sanitizeProperties } from "@guidelytech/shared/runtime";
20
+ import { evaluateRules, randomId, rolloutBucket, sanitizeProperties, supportsPlatform } from "@guidelytech/shared/runtime";
7
21
  import {
8
22
  destroyHost,
9
23
  isChecklistComplete,
@@ -61,9 +75,9 @@ function captureSnapshot(doc, maskSelectors, maxBytes = MAX_SNAPSHOT_BYTES) {
61
75
  }
62
76
  }
63
77
  clone.querySelectorAll("*").forEach((el) => {
64
- for (const attr of Array.from(el.attributes)) {
65
- const name = attr.name.toLowerCase();
66
- if (name.startsWith("on") || (name === "href" || name === "src" || name === "action") && /^\s*javascript:/i.test(attr.value)) el.removeAttribute(attr.name);
78
+ for (const attr2 of Array.from(el.attributes)) {
79
+ const name = attr2.name.toLowerCase();
80
+ if (name.startsWith("on") || (name === "href" || name === "src" || name === "action") && /^\s*javascript:/i.test(attr2.value)) el.removeAttribute(attr2.name);
67
81
  }
68
82
  if (el instanceof HTMLInputElement) {
69
83
  el.removeAttribute("value");
@@ -95,78 +109,6 @@ ${clone.outerHTML}`;
95
109
  return { html, url: win.location.href, title: doc.title, width: win.innerWidth, height: win.innerHeight, capturedAt: (/* @__PURE__ */ new Date()).toISOString() };
96
110
  }
97
111
 
98
- // src/storage.ts
99
- function memoryStore() {
100
- const map = /* @__PURE__ */ new Map();
101
- return { get: (k) => map.get(k) ?? null, set: (k, v) => void map.set(k, v), remove: (k) => void map.delete(k) };
102
- }
103
- function webStore(area) {
104
- try {
105
- if (typeof window === "undefined") return null;
106
- const s = window[area];
107
- if (!s) return null;
108
- const probe = "__guidely_probe__";
109
- s.setItem(probe, "1");
110
- s.removeItem(probe);
111
- return {
112
- get: (k) => {
113
- try {
114
- return s.getItem(k);
115
- } catch {
116
- return null;
117
- }
118
- },
119
- set: (k, v) => {
120
- try {
121
- s.setItem(k, v);
122
- } catch {
123
- }
124
- },
125
- remove: (k) => {
126
- try {
127
- s.removeItem(k);
128
- } catch {
129
- }
130
- }
131
- };
132
- } catch {
133
- return null;
134
- }
135
- }
136
- function createStorage(kind = "local") {
137
- if (kind === "memory") return memoryStore();
138
- return webStore(kind === "local" ? "localStorage" : "sessionStorage") ?? memoryStore();
139
- }
140
- function readJson(store, key, fallback) {
141
- const raw = store.get(key);
142
- if (raw == null) return fallback;
143
- try {
144
- return JSON.parse(raw);
145
- } catch {
146
- return fallback;
147
- }
148
- }
149
- function writeJson(store, key, value) {
150
- try {
151
- store.set(key, JSON.stringify(value));
152
- } catch {
153
- }
154
- }
155
- var KEYS = {
156
- anon: "guidely:anon",
157
- seen: "guidely:seen",
158
- config: (key) => `guidely:config:${key}`,
159
- etag: (key) => `guidely:etag:${key}`,
160
- state: (key) => `guidely:state:${key}`,
161
- user: (key) => `guidely:user:${key}`,
162
- queue: (key) => `guidely:queue:${key}`,
163
- session: "guidely:session",
164
- active: "guidely:active",
165
- authoring: "guidely:authoring",
166
- /** Messenger conversations this browser can read: [{ id, token }]. */
167
- conversations: (key) => `guidely:conversations:${key}`
168
- };
169
-
170
112
  // src/authoring.ts
171
113
  async function loadSchemas() {
172
114
  const m = await import("@guidelytech/shared");
@@ -396,6 +338,134 @@ var AuthoringBridge = class {
396
338
  }
397
339
  };
398
340
 
341
+ // src/autocapture.ts
342
+ import { isGuidelyElement, isStableId } from "@guidelytech/guide-runtime";
343
+ var INTERACTIVE_SELECTOR = "a,button,input[type=button],input[type=submit],input[type=reset],input[type=checkbox],input[type=radio],select,summary,label,[role=button],[role=link],[role=tab],[role=menuitem],[role=checkbox],[role=switch],[role=option],[onclick],[data-guidely-track]";
344
+ var DEFAULT_MASK_SELECTORS = ["[data-guidely-mask]", ".guidely-mask"];
345
+ var PREFERRED_ATTRS = ["data-testid", "data-test-id", "data-test", "data-cy", "data-qa", "data-guidely-track", "data-tour"];
346
+ var NOISY_DATA_ATTR = /^data-(state|v-|react|radix|headlessui|svelte|orientation|highlighted|focus|key|index|side|align|disabled|selected|active|open|guidely-(mask|ignore|block))/i;
347
+ function validSelectors(list) {
348
+ const out = [];
349
+ const probe = typeof document !== "undefined" ? document.createDocumentFragment() : null;
350
+ for (const raw of list) {
351
+ const s = (raw || "").trim();
352
+ if (!s || out.includes(s)) continue;
353
+ try {
354
+ probe?.querySelector(s);
355
+ out.push(s);
356
+ } catch {
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+ function isIgnoredElement(el) {
362
+ return isGuidelyElement(el) || !!el.closest("[data-guidely-ignore]");
363
+ }
364
+ function interactiveElement(target) {
365
+ try {
366
+ return target.closest(INTERACTIVE_SELECTOR);
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+ function esc(value) {
372
+ const g = globalThis;
373
+ return g.CSS?.escape ? g.CSS.escape(value) : value.replace(/[^\w-]/g, (c) => `\\${c}`);
374
+ }
375
+ var attr = (tag, name, value) => value ? `${tag}[${name}="${value.replace(/["\\]/g, "\\$&")}"]` : `${tag}[${name}]`;
376
+ function elementSelector(el) {
377
+ const tag = el.tagName.toLowerCase();
378
+ if (el.id && isStableId(el.id)) return `#${esc(el.id)}`;
379
+ for (const name of PREFERRED_ATTRS) if (el.hasAttribute(name)) return attr(tag, name, el.getAttribute(name) || "").slice(0, 300);
380
+ for (const a of Array.from(el.attributes)) {
381
+ if (a.name.startsWith("data-") && !NOISY_DATA_ATTR.test(a.name) && a.value && a.value.length <= 60) return attr(tag, a.name, a.value).slice(0, 300);
382
+ }
383
+ const label = el.getAttribute("aria-label");
384
+ if (label && label.length <= 80) return attr(tag, "aria-label", label).slice(0, 300);
385
+ const parts = [];
386
+ let cur = el;
387
+ while (cur && parts.length < 4) {
388
+ const t = cur.tagName.toLowerCase();
389
+ if (t === "html" || t === "body") break;
390
+ if (cur !== el && cur.id && isStableId(cur.id)) {
391
+ parts.unshift(`#${esc(cur.id)}`);
392
+ break;
393
+ }
394
+ const parent = cur.parentElement;
395
+ const node = cur;
396
+ const same = parent ? Array.from(parent.children).filter((c) => c.tagName === node.tagName) : [];
397
+ parts.unshift(same.length > 1 ? `${t}:nth-of-type(${same.indexOf(node) + 1})` : t);
398
+ cur = parent;
399
+ }
400
+ return (parts.join(" > ") || tag).slice(0, 300);
401
+ }
402
+ function matches(el, selector, deep) {
403
+ if (!selector) return false;
404
+ try {
405
+ return !!el.closest(selector) || deep && !!el.querySelector(selector);
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
410
+ function elementText(el, mask, maskAll) {
411
+ if (maskAll || matches(el, mask, true)) return void 0;
412
+ const tag = el.tagName.toLowerCase();
413
+ let text = "";
414
+ if (tag === "input") {
415
+ if (!/^(button|submit|reset)$/i.test(el.type || "")) return void 0;
416
+ text = el.value;
417
+ } else if (tag === "select" || tag === "textarea") return void 0;
418
+ else text = el.innerText ?? el.textContent ?? "";
419
+ text = (text || el.getAttribute("aria-label") || el.getAttribute("title") || "").replace(/\s+/g, " ").trim();
420
+ return text ? text.slice(0, 100) : void 0;
421
+ }
422
+ function linkHref(el) {
423
+ const a = el.closest("a[href]");
424
+ if (!a) return void 0;
425
+ try {
426
+ const u = new URL(a.getAttribute("href") || "", location.href);
427
+ if (!/^https?:$/.test(u.protocol)) return void 0;
428
+ return u.origin === location.origin ? u.pathname : u.origin;
429
+ } catch {
430
+ return void 0;
431
+ }
432
+ }
433
+ function describeClick(el, e, opts) {
434
+ const props = { tag: el.tagName.toLowerCase(), selector: elementSelector(el), x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
435
+ const text = elementText(el, opts.mask, opts.maskAll);
436
+ if (text) props.text = text;
437
+ const href = opts.collectUrls ? linkHref(el) : void 0;
438
+ if (href) props.href = href;
439
+ return props;
440
+ }
441
+ var RageClickDetector = class {
442
+ constructor(threshold = 3, windowMs = 1e3, radius = 30) {
443
+ __publicField(this, "threshold", threshold);
444
+ __publicField(this, "windowMs", windowMs);
445
+ __publicField(this, "radius", radius);
446
+ __publicField(this, "clicks", []);
447
+ __publicField(this, "fired", false);
448
+ }
449
+ /** Clicks in the current burst once it has fired (rage), otherwise 0. */
450
+ get burst() {
451
+ return this.fired ? this.clicks.length : 0;
452
+ }
453
+ /** Records a click; returns the click count when it completes a burst, otherwise 0. */
454
+ add(x, y, t) {
455
+ const first = this.clicks[0];
456
+ const last = this.clicks[this.clicks.length - 1];
457
+ if (!first || !last || t - last.t > this.windowMs || Math.hypot(x - first.x, y - first.y) > this.radius) {
458
+ this.clicks = [];
459
+ this.fired = false;
460
+ }
461
+ this.clicks = this.clicks.filter((c) => t - c.t <= this.windowMs);
462
+ this.clicks.push({ x, y, t });
463
+ if (this.fired || this.clicks.length < this.threshold) return 0;
464
+ this.fired = true;
465
+ return this.clicks.length;
466
+ }
467
+ };
468
+
399
469
  // src/context.ts
400
470
  function detectDevice(ua, width) {
401
471
  const u = ua || "";
@@ -452,228 +522,153 @@ function buildContext(input) {
452
522
  audienceIds: new Set(input.audienceIds),
453
523
  now: input.now ?? /* @__PURE__ */ new Date(),
454
524
  environment: input.environment,
455
- locale: input.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0)
525
+ locale: input.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0),
526
+ platform: input.platform ?? "web",
527
+ ...input.screen !== void 0 ? { screen: input.screen } : {},
528
+ ...input.appVersion !== void 0 ? { appVersion: input.appVersion } : {}
456
529
  };
457
530
  }
458
531
 
459
- // src/emitter.ts
460
- var Emitter = class {
461
- constructor(onError) {
462
- __publicField(this, "onError", onError);
463
- __publicField(this, "listeners", /* @__PURE__ */ new Map());
464
- }
465
- on(event, cb) {
466
- let set = this.listeners.get(event);
467
- if (!set) {
468
- set = /* @__PURE__ */ new Set();
469
- this.listeners.set(event, set);
532
+ // src/heatmap.ts
533
+ var HEATMAP_MAX_POINTS = 40;
534
+ var HEATMAP_MAX_BYTES = 6e3;
535
+ var HEATMAP_MAX_BINS = 60;
536
+ var r2 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 100) / 100;
537
+ function heatmapPoint(el, e, rage) {
538
+ const p = { k: rage ? "r" : "c", x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
539
+ const s = elementSelector(el).slice(0, 120);
540
+ if (s) p.s = s;
541
+ const rect = el.getBoundingClientRect();
542
+ if (rect.width > 0 && rect.height > 0) {
543
+ p.rx = r2((e.clientX - rect.left) / rect.width);
544
+ p.ry = r2((e.clientY - rect.top) / rect.height);
545
+ }
546
+ return p;
547
+ }
548
+ var selectors = /* @__PURE__ */ new WeakMap();
549
+ var r1 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 10) / 10;
550
+ function movementBin(el, e) {
551
+ const p = { k: "m", x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
552
+ let s = /^(HTML|BODY)$/.test(el.tagName) ? "" : selectors.get(el);
553
+ if (s === void 0) selectors.set(el, s = elementSelector(el).slice(0, 120));
554
+ const rect = s ? el.getBoundingClientRect() : null;
555
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return [`${p.x >> 4},${p.y >> 4}`, p];
556
+ p.s = s;
557
+ p.rx = r1((e.clientX - rect.left) / rect.width);
558
+ p.ry = r1((e.clientY - rect.top) / rect.height);
559
+ return [`${s} ${p.rx},${p.ry}`, p];
560
+ }
561
+ var HeatmapBuffer = class {
562
+ constructor() {
563
+ /** The page the buffered points belong to. */
564
+ __publicField(this, "url", "");
565
+ __publicField(this, "points", []);
566
+ __publicField(this, "bins", /* @__PURE__ */ new Map());
567
+ __publicField(this, "bytes", 0);
568
+ }
569
+ /** Adds a click (`burst` > 0 marks it and the burst's previous clicks as rage); true when the batch is full. */
570
+ add(url, point, burst) {
571
+ this.url = url;
572
+ for (let i = Math.max(0, this.points.length - burst + 1); i < this.points.length; i++) this.points[i].k = "r";
573
+ this.points.push(point);
574
+ this.bytes += JSON.stringify(point).length + 1;
575
+ return this.full();
576
+ }
577
+ /** Adds a movement sample to its bin (weight +1); true when the batch is full. */
578
+ move(url, [key, point]) {
579
+ this.url = url;
580
+ const bin = this.bins.get(key);
581
+ if (bin) bin.w = (bin.w ?? 1) + 1;
582
+ else {
583
+ this.bins.set(key, point);
584
+ this.bytes += JSON.stringify(point).length + 8;
470
585
  }
471
- set.add(cb);
472
- return () => this.off(event, cb);
586
+ return this.full();
473
587
  }
474
- off(event, cb) {
475
- this.listeners.get(event)?.delete(cb);
476
- }
477
- emit(event, ...args) {
478
- const set = this.listeners.get(event);
479
- if (!set) return;
480
- for (const cb of Array.from(set)) {
481
- try {
482
- cb(...args);
483
- } catch (err) {
484
- this.onError?.(err);
485
- }
486
- }
588
+ full() {
589
+ return this.points.length >= HEATMAP_MAX_POINTS || this.bins.size > HEATMAP_MAX_BINS || this.bytes >= HEATMAP_MAX_BYTES;
487
590
  }
488
- clear() {
489
- this.listeners.clear();
591
+ /** Empties the buffer, returning what was in it (clicks first, then movement bins). */
592
+ take() {
593
+ const points = [...this.points, ...this.bins.values()];
594
+ this.points = [];
595
+ this.bins = /* @__PURE__ */ new Map();
596
+ this.bytes = 0;
597
+ return points.length ? { url: this.url, points } : null;
490
598
  }
491
599
  };
492
600
 
493
- // src/frequency.ts
494
- var HOUR = 3600 * 1e3;
495
- function isFrequencyEligible(freq, state, shownThisSession, now = Date.now()) {
496
- const s = state ?? { impressions: 0 };
497
- if (freq.maxImpressions != null && s.impressions >= freq.maxImpressions) return false;
498
- if (s.completedAt && freq.mode !== "every_time") return false;
499
- if (s.dismissedAt) {
500
- if (!freq.reshowAfterDismiss) return false;
501
- if (freq.cooldownHours > 0 && now - Date.parse(s.dismissedAt) < freq.cooldownHours * HOUR) return false;
502
- }
503
- switch (freq.mode) {
504
- case "once":
505
- return s.impressions === 0 || !!s.dismissedAt && freq.reshowAfterDismiss;
506
- case "once_per_session":
507
- return !shownThisSession;
508
- case "interval": {
509
- if (!s.lastShownAt) return true;
510
- const hours = freq.intervalHours ?? 24;
511
- return now - Date.parse(s.lastShownAt) >= hours * HOUR;
512
- }
513
- case "every_time":
514
- default:
515
- return true;
601
+ // src/errors.ts
602
+ import { parseStack } from "@guidelytech/shared/runtime";
603
+ var MAX_FRAMES_BYTES = 6e3;
604
+ function fitFrames(frames) {
605
+ const out = frames.slice(0, 30);
606
+ while (out.length > 1 && JSON.stringify(out).length > MAX_FRAMES_BYTES) out.pop();
607
+ return out;
608
+ }
609
+ function describe(value) {
610
+ if (typeof value === "string") return value;
611
+ try {
612
+ return JSON.stringify(value) ?? String(value);
613
+ } catch {
614
+ return String(value);
516
615
  }
517
616
  }
518
- function isExclusiveGuide(guide) {
519
- if (guide.kind === "banner" || guide.kind === "checklist") return false;
520
- const steps = guide.config.steps;
521
- if (steps.length > 0 && steps.every((s) => s.type === "hotspot" || s.type === "banner")) return false;
522
- return true;
617
+ function exceptionFromError(error, source, handled, fallbackType = "Error") {
618
+ const isError = error instanceof Error || !!error && typeof error === "object" && typeof error.message === "string" && typeof error.stack === "string";
619
+ const e = error;
620
+ return {
621
+ type: isError && e.name || fallbackType,
622
+ message: (isError ? e.message : describe(error)).slice(0, 1e3),
623
+ frames: isError ? fitFrames(parseStack(e.stack)) : [],
624
+ source,
625
+ handled
626
+ };
523
627
  }
524
- function areMutuallyExclusive(a, b) {
525
- const la = a.config.frequency.mutuallyExclusiveWith;
526
- const lb = b.config.frequency.mutuallyExclusiveWith;
527
- return la.includes(b.id) || la.includes(b.key) || lb.includes(a.id) || lb.includes(a.key);
628
+ function errorEventToException(e) {
629
+ const target = e.target;
630
+ if (target && typeof Element !== "undefined" && target instanceof Element) return null;
631
+ if (e.error != null) return exceptionFromError(e.error, "onerror", false);
632
+ const message = e.message || "";
633
+ if (!message || /^script error\.?$/i.test(message.trim())) return null;
634
+ const m = /^(?:Uncaught )?([A-Z]\w*(?:Error|Exception)):\s*([\s\S]*)$/.exec(message);
635
+ return {
636
+ type: m?.[1] ?? "Error",
637
+ message: (m?.[2] ?? message).slice(0, 1e3),
638
+ frames: e.filename ? [{ file: e.filename.slice(0, 500), line: e.lineno || void 0, col: e.colno || void 0 }] : [],
639
+ source: "onerror",
640
+ handled: false
641
+ };
528
642
  }
529
- function isWithinSchedule(guide, now = Date.now()) {
530
- const s = guide.config.survey;
531
- if (!s) return true;
532
- if (s.startAt && now < Date.parse(s.startAt)) return false;
533
- if (s.endAt && now > Date.parse(s.endAt)) return false;
534
- return true;
643
+ function rejectionToException(e) {
644
+ return exceptionFromError(e.reason, "unhandledrejection", false, "UnhandledRejection");
535
645
  }
536
-
537
- // src/http.ts
538
- var Api = class {
539
- constructor(baseUrl, key, fetchImpl) {
540
- __publicField(this, "baseUrl", baseUrl);
541
- __publicField(this, "key", key);
542
- __publicField(this, "fetchImpl", fetchImpl);
543
- }
544
- url(path, params = {}) {
545
- const u = new URL(`${this.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}`);
546
- u.searchParams.set("key", this.key);
547
- for (const [k, v] of Object.entries(params)) if (v != null) u.searchParams.set(k, v);
548
- return u.toString();
549
- }
550
- get fetcher() {
551
- if (this.fetchImpl) return this.fetchImpl;
552
- return typeof fetch === "function" ? fetch.bind(globalThis) : null;
553
- }
554
- async request(method, path, options = {}) {
555
- const f = this.fetcher;
556
- if (!f) throw new Error("fetch is not available");
557
- const headers = { ...options.headers, "X-Guidely-Key": this.key, Accept: "application/json" };
558
- if (options.body !== void 0) headers["Content-Type"] = "application/json";
559
- if (options.etag) headers["If-None-Match"] = options.etag;
560
- const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
561
- const timer = ac ? setTimeout(() => ac.abort(), options.timeoutMs ?? 1e4) : null;
562
- try {
563
- const res = await f(this.url(path, options.params), {
564
- method,
565
- headers,
566
- body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
567
- keepalive: options.keepalive,
568
- signal: ac?.signal,
569
- credentials: "omit",
570
- mode: "cors"
571
- });
572
- let data = null;
573
- if (res.status !== 304 && res.status !== 204) {
574
- try {
575
- data = await res.json();
576
- } catch {
577
- data = null;
578
- }
579
- }
580
- return { status: res.status, ok: res.ok, data, etag: res.headers?.get?.("etag") ?? null };
581
- } finally {
582
- if (timer) clearTimeout(timer);
583
- }
584
- }
585
- get(path, options) {
586
- return this.request("GET", path, options);
587
- }
588
- post(path, body, options) {
589
- return this.request("POST", path, { body, ...options });
590
- }
591
- /**
592
- * POST that may answer with server-sent events. Each event is passed to `onEvent`; a JSON answer is
593
- * returned instead. No timeout: Scout replies can take a while and the server keeps the stream alive.
594
- */
595
- async stream(path, body, onEvent, headers = {}) {
596
- const f = this.fetcher;
597
- if (!f) throw new Error("fetch is not available");
598
- const res = await f(this.url(path), {
599
- method: "POST",
600
- headers: { ...headers, "X-Guidely-Key": this.key, Accept: "text/event-stream, application/json", "Content-Type": "application/json" },
601
- body: JSON.stringify(body),
602
- credentials: "omit",
603
- mode: "cors"
604
- });
605
- const type = res.headers?.get?.("content-type") ?? "";
606
- if (!type.includes("text/event-stream") || !res.body) {
607
- let data = null;
608
- try {
609
- data = await res.json();
610
- } catch {
611
- data = null;
612
- }
613
- return { status: res.status, ok: res.ok, data, etag: null };
614
- }
615
- await readEventStream(res.body, onEvent);
616
- return { status: res.status, ok: res.ok, data: null, etag: null };
617
- }
618
- /** `navigator.sendBeacon` when available (page unload); returns false when it could not be queued. */
619
- beacon(path, body) {
620
- try {
621
- if (typeof navigator === "undefined" || typeof navigator.sendBeacon !== "function") return false;
622
- const blob = new Blob([JSON.stringify(body)], { type: "text/plain;charset=UTF-8" });
623
- return navigator.sendBeacon(this.url(path), blob);
624
- } catch {
625
- return false;
626
- }
627
- }
628
- };
629
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
630
- function backoffMs(attempt, baseMs = 1e3, maxMs = 3e4) {
631
- return Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt));
632
- }
633
- async function postWithRetry(api, path, body, attempts = 3, baseMs = 500) {
634
- for (let i = 0; i < attempts; i++) {
635
- try {
636
- const res = await api.post(path, body);
637
- if (res.ok || res.status >= 400 && res.status < 500 && res.status !== 429) return res;
638
- } catch {
639
- }
640
- if (i < attempts - 1) await sleep(backoffMs(i, baseMs, 8e3));
646
+ var GUIDELY_SCRIPT = /\/guidely(-recorder)?(\.min)?\.js$/i;
647
+ function isGuidelyException(props) {
648
+ if (props.message.startsWith("[Guidely]")) return true;
649
+ const file = props.frames[0]?.file;
650
+ if (!file) return false;
651
+ try {
652
+ return GUIDELY_SCRIPT.test(new URL(file).pathname);
653
+ } catch {
654
+ return GUIDELY_SCRIPT.test(file.replace(/[?#].*$/, ""));
641
655
  }
642
- return null;
643
656
  }
644
- async function readEventStream(body, onEvent) {
645
- const reader = body.getReader();
646
- const decoder = new TextDecoder();
647
- let buffer = "";
648
- const dispatch = (raw) => {
649
- let event = "message";
650
- const data = [];
651
- for (const line of raw.split("\n")) {
652
- if (!line || line.startsWith(":")) continue;
653
- const i = line.indexOf(":");
654
- const field = i < 0 ? line : line.slice(0, i);
655
- const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
656
- if (field === "event") event = value;
657
- else if (field === "data") data.push(value);
658
- }
659
- if (!data.length) return;
660
- try {
661
- onEvent(event, JSON.parse(data.join("\n")));
662
- } catch {
663
- }
664
- };
665
- for (; ; ) {
666
- const { done, value } = await reader.read();
667
- if (value) buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
668
- let sep;
669
- while ((sep = buffer.indexOf("\n\n")) >= 0) {
670
- dispatch(buffer.slice(0, sep));
671
- buffer = buffer.slice(sep + 2);
672
- }
673
- if (done) break;
657
+ var ExceptionThrottle = class {
658
+ constructor(max = 5) {
659
+ __publicField(this, "max", max);
660
+ __publicField(this, "seen", /* @__PURE__ */ new Map());
661
+ }
662
+ allow(props) {
663
+ const top = props.frames[0];
664
+ const key = `${props.type}|${props.message}|${top ? `${top.file}:${top.line}:${top.col}` : ""}`;
665
+ const n = this.seen.get(key) ?? 0;
666
+ if (n >= this.max) return false;
667
+ if (this.seen.size > 500) this.seen.clear();
668
+ this.seen.set(key, n + 1);
669
+ return true;
674
670
  }
675
- if (buffer.trim()) dispatch(buffer);
676
- }
671
+ };
677
672
 
678
673
  // src/messenger-api.ts
679
674
  var MAX_STORED = 50;
@@ -834,124 +829,61 @@ function watchNavigation(listener, debounceMs = 50) {
834
829
  };
835
830
  }
836
831
 
837
- // src/queue.ts
838
- var EventQueue = class {
839
- constructor(opts) {
840
- __publicField(this, "opts", opts);
841
- __publicField(this, "events", []);
842
- __publicField(this, "timer", null);
843
- __publicField(this, "retryTimer", null);
844
- __publicField(this, "inflight", false);
845
- __publicField(this, "inflightIds", /* @__PURE__ */ new Set());
846
- __publicField(this, "attempt", 0);
847
- __publicField(this, "destroyed", false);
848
- __publicField(this, "flushIntervalMs");
849
- __publicField(this, "maxQueue");
850
- __publicField(this, "batchSize");
851
- __publicField(this, "maxAttempts");
852
- this.flushIntervalMs = opts.flushIntervalMs ?? 2e3;
853
- this.maxQueue = opts.maxQueue ?? 200;
854
- this.batchSize = opts.batchSize ?? 50;
855
- this.maxAttempts = opts.maxAttempts ?? 5;
856
- const persisted = readJson(opts.store, opts.storageKey, []);
857
- if (Array.isArray(persisted)) this.events = persisted.filter((e) => e && typeof e.eventId === "string").slice(-this.maxQueue);
858
- if (this.events.length) this.schedule();
859
- }
860
- get size() {
861
- return this.events.length;
862
- }
863
- /** Pending events (read-only copy, for debugging). */
864
- peek() {
865
- return this.events.slice();
866
- }
867
- enqueue(event) {
868
- if (this.destroyed) return;
869
- if (this.events.some((e) => e.eventId === event.eventId)) return;
870
- this.events.push(event);
871
- if (this.events.length > this.maxQueue) this.events.splice(0, this.events.length - this.maxQueue);
872
- this.persist();
873
- this.schedule();
874
- }
875
- persist() {
876
- writeJson(this.opts.store, this.opts.storageKey, this.events);
877
- }
878
- schedule() {
879
- if (this.timer || this.retryTimer || this.destroyed) return;
880
- this.timer = setTimeout(() => {
881
- this.timer = null;
882
- void this.flush();
883
- }, this.flushIntervalMs);
884
- }
885
- /** Sends queued events now (one batch at a time). Safe to call at any time. */
886
- async flush() {
887
- if (this.destroyed || this.inflight || !this.events.length || !this.opts.enabled()) return;
888
- if (this.timer) {
889
- clearTimeout(this.timer);
890
- this.timer = null;
891
- }
892
- this.inflight = true;
893
- const batch = this.events.slice(0, this.batchSize);
894
- this.inflightIds = new Set(batch.map((e) => e.eventId));
895
- let ok = false;
896
- let retry = true;
832
+ // src/page-tracking.ts
833
+ function scrollDepth() {
834
+ const de = document.documentElement;
835
+ const height = Math.max(de?.scrollHeight || 0, document.body?.scrollHeight || 0);
836
+ const view = window.innerHeight || de?.clientHeight || 0;
837
+ if (height <= 0 || height <= view) return 100;
838
+ const y = window.scrollY || de?.scrollTop || 0;
839
+ return Math.max(0, Math.min(100, Math.round((y + view) / height * 100)));
840
+ }
841
+ var PageTracker = class {
842
+ constructor() {
843
+ __publicField(this, "url", "");
844
+ __publicField(this, "visibleMs", 0);
845
+ __publicField(this, "visibleSince", null);
846
+ __publicField(this, "maxScroll", 0);
847
+ }
848
+ /** Starts measuring a new page. */
849
+ start(url, now = Date.now()) {
850
+ this.url = url;
851
+ this.visibleMs = 0;
852
+ this.visibleSince = typeof document !== "undefined" && document.visibilityState === "hidden" ? null : now;
853
+ this.maxScroll = 0;
854
+ this.measureScroll();
855
+ }
856
+ /** Pauses / resumes the visible-time clock. */
857
+ setHidden(hidden, now = Date.now()) {
858
+ if (hidden && this.visibleSince !== null) {
859
+ this.visibleMs += Math.max(0, now - this.visibleSince);
860
+ this.visibleSince = null;
861
+ } else if (!hidden && this.visibleSince === null) this.visibleSince = now;
862
+ }
863
+ measureScroll() {
897
864
  try {
898
- const res = await this.opts.send(batch);
899
- ok = res.ok;
900
- retry = res.retry ?? !res.ok;
901
- } catch (err) {
902
- this.opts.onError?.(err);
903
- }
904
- this.inflight = false;
905
- this.inflightIds.clear();
906
- if (this.destroyed) return;
907
- if (ok || !retry) {
908
- const ids = new Set(batch.map((e) => e.eventId));
909
- this.events = this.events.filter((e) => !ids.has(e.eventId));
910
- this.attempt = 0;
911
- this.persist();
912
- if (this.events.length) this.schedule();
913
- return;
914
- }
915
- this.attempt += 1;
916
- if (this.attempt >= this.maxAttempts) {
917
- this.attempt = 0;
918
- return;
919
- }
920
- this.retryTimer = setTimeout(() => {
921
- this.retryTimer = null;
922
- void this.flush();
923
- }, backoffMs(this.attempt - 1));
924
- }
925
- /** Best-effort synchronous flush on unload using `sendBeacon`. */
926
- flushSync() {
927
- if (this.destroyed || !this.opts.enabled() || !this.opts.beacon) return;
928
- const batch = this.events.filter((e) => !this.inflightIds.has(e.eventId)).slice(0, this.batchSize);
929
- if (!batch.length) return;
930
- if (this.opts.beacon(batch)) {
931
- const ids = new Set(batch.map((e) => e.eventId));
932
- this.events = this.events.filter((e) => !ids.has(e.eventId));
933
- this.persist();
865
+ this.maxScroll = Math.max(this.maxScroll, scrollDepth());
866
+ } catch {
934
867
  }
935
868
  }
936
- clear() {
937
- this.events = [];
938
- this.persist();
939
- }
940
- destroy() {
941
- this.destroyed = true;
942
- if (this.timer) clearTimeout(this.timer);
943
- if (this.retryTimer) clearTimeout(this.retryTimer);
944
- this.timer = null;
945
- this.retryTimer = null;
946
- this.persist();
869
+ /** Final numbers for the current page. */
870
+ leave(now = Date.now()) {
871
+ this.measureScroll();
872
+ const running = this.visibleSince !== null ? Math.max(0, now - this.visibleSince) : 0;
873
+ return { url: this.url, durationMs: Math.round(this.visibleMs + running), scrollDepth: this.maxScroll };
947
874
  }
948
875
  };
949
876
 
877
+ // src/load-recorder.ts
878
+ var loadRecorder = async () => (await import("./recorder.js")).recorder;
879
+
950
880
  // src/client.ts
951
881
  var SDK_NAME = "@guidelytech/sdk-core";
952
- var SDK_VERSION = "0.2.0";
882
+ var SDK_VERSION = "0.4.0";
953
883
  var DEFAULT_API_URL = "https://api.guidely.app";
954
884
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
885
+ var DAY_MS = 24 * 60 * 60 * 1e3;
886
+ var MAX_SESSION_EXCEPTIONS = 100;
955
887
  function isBrowser() {
956
888
  return typeof window !== "undefined" && typeof document !== "undefined";
957
889
  }
@@ -961,6 +893,9 @@ function nowIso() {
961
893
  function emptyState() {
962
894
  return { guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] };
963
895
  }
896
+ function newSession(now = Date.now()) {
897
+ return { id: `s_${randomId(16)}`, startedAt: new Date(now).toISOString(), shownGuides: [], events: [], lastActivityAt: now };
898
+ }
964
899
  var GuidelyClient = class _GuidelyClient {
965
900
  constructor(options) {
966
901
  __publicField(this, "options");
@@ -998,6 +933,19 @@ var GuidelyClient = class _GuidelyClient {
998
933
  __publicField(this, "lastAudiences", /* @__PURE__ */ new Set());
999
934
  /** Set once the app calls identify()/reset(); bootstrap then leaves identity alone instead of re-identifying. */
1000
935
  __publicField(this, "identitySettled", false);
936
+ // Product analytics (autocapture, error tracking, session replay)
937
+ /** Created once the recorder chunk has loaded (it carries the replay controller). */
938
+ __publicField(this, "replay", null);
939
+ __publicField(this, "replayLoading", false);
940
+ __publicField(this, "pageTracker", new PageTracker());
941
+ __publicField(this, "rage", new RageClickDetector());
942
+ __publicField(this, "heatmap", new HeatmapBuffer());
943
+ __publicField(this, "exceptionThrottle", new ExceptionThrottle());
944
+ /** Exceptions seen before the config was loaded (reported once it is known whether the server accepts them). */
945
+ __publicField(this, "earlyExceptions", []);
946
+ __publicField(this, "initialPageViewSent", false);
947
+ __publicField(this, "pageViewUrl", null);
948
+ __publicField(this, "pageLeft", false);
1001
949
  __publicField(this, "customListeners", /* @__PURE__ */ new Map());
1002
950
  __publicField(this, "eventBus", {
1003
951
  on: (name, cb) => {
@@ -1040,7 +988,7 @@ var GuidelyClient = class _GuidelyClient {
1040
988
  this.state = { ...emptyState(), ...readJson(this.store, KEYS.state(this.options.projectId), {}) };
1041
989
  this.user = readJson(this.store, KEYS.user(this.options.projectId), null);
1042
990
  const session = readJson(this.sessionStore, KEYS.session, null);
1043
- this.session = session && session.id ? session : { id: `s_${randomId(16)}`, startedAt: nowIso(), shownGuides: [], events: [] };
991
+ this.session = session && session.id ? session : newSession();
1044
992
  this.persistSession();
1045
993
  this.lastUrl = location.href;
1046
994
  this.lastAudiences = new Set(this.state.audienceIds);
@@ -1074,10 +1022,11 @@ var GuidelyClient = class _GuidelyClient {
1074
1022
  return this.init();
1075
1023
  }
1076
1024
  async bootstrap() {
1025
+ this.wireErrorListeners();
1077
1026
  try {
1078
1027
  const cached = readJson(this.store, KEYS.config(this.options.projectId), null);
1079
1028
  this.etag = this.store.get(KEYS.etag(this.options.projectId));
1080
- if (cached && cached.guides) this.config = cached;
1029
+ if (cached && cached.guides) this.config = webOnly(cached);
1081
1030
  await this.fetchConfig();
1082
1031
  if (this.destroyed) return;
1083
1032
  this.wireListeners();
@@ -1093,6 +1042,9 @@ var GuidelyClient = class _GuidelyClient {
1093
1042
  this.resumeActiveGuide();
1094
1043
  this.evaluate("ready");
1095
1044
  if (!this.options.disableAuthoring) this.maybeStartAuthoring();
1045
+ this.pageTracker.start(location.href);
1046
+ this.touchSession();
1047
+ this.syncCapture();
1096
1048
  } catch (err) {
1097
1049
  this.reportError(err);
1098
1050
  this.isReady = true;
@@ -1101,10 +1053,10 @@ var GuidelyClient = class _GuidelyClient {
1101
1053
  }
1102
1054
  async fetchConfig() {
1103
1055
  try {
1104
- const res = await this.api.get("/config", { etag: this.etag });
1056
+ const res = await this.api.get("/config", { etag: this.etag, params: { platform: "web" } });
1105
1057
  if (res.status === 304) return true;
1106
1058
  if (res.ok && res.data && Array.isArray(res.data.guides)) {
1107
- this.config = res.data;
1059
+ this.config = webOnly(res.data);
1108
1060
  this.etag = res.etag;
1109
1061
  writeJson(this.store, KEYS.config(this.options.projectId), res.data);
1110
1062
  if (res.etag) this.store.set(KEYS.etag(this.options.projectId), res.etag);
@@ -1127,14 +1079,62 @@ var GuidelyClient = class _GuidelyClient {
1127
1079
  const onClick = (e) => this.handleDocumentClick(e);
1128
1080
  doc.addEventListener("click", onClick, true);
1129
1081
  this.cleanups.push(() => doc.removeEventListener("click", onClick, true));
1130
- const onHide = () => {
1131
- if (doc.visibilityState === "hidden") this.queue.flushSync();
1082
+ const onVisibility = () => {
1083
+ const hidden = doc.visibilityState === "hidden";
1084
+ this.pageTracker.setHidden(hidden);
1085
+ if (!hidden) return;
1086
+ this.replay?.flushOnHide();
1087
+ this.queue.flushSync();
1088
+ };
1089
+ const onPageHide = () => {
1090
+ this.flushHeatmap();
1091
+ if (!this.pageLeft) this.leavePage(null);
1092
+ this.pageLeft = true;
1093
+ this.replay?.flushOnHide();
1094
+ this.queue.flushSync();
1095
+ };
1096
+ const onPageShow = (e) => {
1097
+ if (!e.persisted || !this.pageLeft) return;
1098
+ this.pageLeft = false;
1099
+ this.pageTracker.start(location.href);
1132
1100
  };
1133
- doc.addEventListener("visibilitychange", onHide);
1134
- win.addEventListener("pagehide", onHide);
1101
+ let lastScroll = 0;
1102
+ const onScroll = () => {
1103
+ const now = Date.now();
1104
+ if (now - lastScroll < 250) return;
1105
+ lastScroll = now;
1106
+ this.pageTracker.measureScroll();
1107
+ this.touchSession();
1108
+ };
1109
+ const onKey = () => this.touchSession();
1110
+ let move = null;
1111
+ let moveTimer = null;
1112
+ const onMove = (e) => {
1113
+ move = e;
1114
+ moveTimer ?? (moveTimer = setTimeout(() => {
1115
+ moveTimer = null;
1116
+ if (move) this.sampleMove(move);
1117
+ move = null;
1118
+ }, 200));
1119
+ };
1120
+ if (win.matchMedia?.("(pointer: fine)").matches) {
1121
+ doc.addEventListener("mousemove", onMove, { passive: true });
1122
+ this.cleanups.push(() => {
1123
+ doc.removeEventListener("mousemove", onMove);
1124
+ if (moveTimer) clearTimeout(moveTimer);
1125
+ });
1126
+ }
1127
+ doc.addEventListener("visibilitychange", onVisibility);
1128
+ win.addEventListener("pagehide", onPageHide);
1129
+ win.addEventListener("pageshow", onPageShow);
1130
+ win.addEventListener("scroll", onScroll, { passive: true });
1131
+ doc.addEventListener("keydown", onKey, true);
1135
1132
  this.cleanups.push(() => {
1136
- doc.removeEventListener("visibilitychange", onHide);
1137
- win.removeEventListener("pagehide", onHide);
1133
+ doc.removeEventListener("visibilitychange", onVisibility);
1134
+ win.removeEventListener("pagehide", onPageHide);
1135
+ win.removeEventListener("pageshow", onPageShow);
1136
+ win.removeEventListener("scroll", onScroll);
1137
+ doc.removeEventListener("keydown", onKey, true);
1138
1138
  });
1139
1139
  const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
1140
1140
  this.cleanups.push(() => clearInterval(refresh));
@@ -1152,6 +1152,18 @@ var GuidelyClient = class _GuidelyClient {
1152
1152
  win.addEventListener("message", onMessage);
1153
1153
  this.cleanups.push(() => win.removeEventListener("message", onMessage));
1154
1154
  }
1155
+ /** Uncaught errors + unhandled rejections; attached before the config loads so boot errors are kept. */
1156
+ wireErrorListeners() {
1157
+ const win = window;
1158
+ const onError = (e) => this.captureGlobalException(errorEventToException(e));
1159
+ const onRejection = (e) => this.captureGlobalException(rejectionToException(e));
1160
+ win.addEventListener("error", onError);
1161
+ win.addEventListener("unhandledrejection", onRejection);
1162
+ this.cleanups.push(() => {
1163
+ win.removeEventListener("error", onError);
1164
+ win.removeEventListener("unhandledrejection", onRejection);
1165
+ });
1166
+ }
1155
1167
  /** Re-fetches the published config and re-evaluates triggers. */
1156
1168
  async refresh() {
1157
1169
  if (this.destroyed) return;
@@ -1161,6 +1173,7 @@ var GuidelyClient = class _GuidelyClient {
1161
1173
  this.renderPersistentWidgets();
1162
1174
  this.evaluate("ready");
1163
1175
  }
1176
+ this.syncCapture();
1164
1177
  }
1165
1178
  /** Tears everything down: guides, widgets, listeners, timers, host element. Re-init is allowed via a new client. */
1166
1179
  destroy() {
@@ -1184,6 +1197,8 @@ var GuidelyClient = class _GuidelyClient {
1184
1197
  this.cleanups.splice(0).forEach((fn) => fn());
1185
1198
  this.authoring?.stop();
1186
1199
  this.authoring = null;
1200
+ this.replay?.destroy();
1201
+ this.replay = null;
1187
1202
  this.queue.flushSync();
1188
1203
  this.queue.destroy();
1189
1204
  this.emitter.clear();
@@ -1270,9 +1285,11 @@ var GuidelyClient = class _GuidelyClient {
1270
1285
  this.store.set(KEYS.anon, this.anonymousId);
1271
1286
  this.state = emptyState();
1272
1287
  this.persistState();
1273
- this.session = { id: `s_${randomId(16)}`, startedAt: nowIso(), shownGuides: [], events: [] };
1288
+ this.session = newSession();
1274
1289
  this.persistSession();
1275
1290
  this.sessionStore.remove(KEYS.active);
1291
+ this.replay?.stop();
1292
+ this.syncCapture();
1276
1293
  this.lastAudiences = /* @__PURE__ */ new Set();
1277
1294
  this.firedDelays.clear();
1278
1295
  this.widgets.forEach((w) => w.destroy());
@@ -1293,6 +1310,8 @@ var GuidelyClient = class _GuidelyClient {
1293
1310
  this.consent = consent;
1294
1311
  if (consent === "denied") this.queue.clear();
1295
1312
  else if (consent === "granted") void this.queue.flush();
1313
+ if (consent !== "granted") this.replay?.stop({ discard: true });
1314
+ this.syncCapture();
1296
1315
  }
1297
1316
  analyticsEnabled() {
1298
1317
  return this.options.analytics && this.consent === "granted" && (this.config?.settings?.analyticsEnabled ?? true);
@@ -1302,6 +1321,7 @@ var GuidelyClient = class _GuidelyClient {
1302
1321
  }
1303
1322
  enqueue(name, extra = {}) {
1304
1323
  if (this.consent === "denied" || !this.options.analytics) return null;
1324
+ if (name !== "page_leave" && name !== "heatmap") this.touchSession();
1305
1325
  const event = {
1306
1326
  eventId: randomId(24),
1307
1327
  name,
@@ -1315,6 +1335,7 @@ var GuidelyClient = class _GuidelyClient {
1315
1335
  };
1316
1336
  if (extra.properties) event.properties = sanitizeProperties(extra.properties);
1317
1337
  this.queue.enqueue(event);
1338
+ this.replay?.addCustomEvent("guidely", { name, customName: event.customName, guideId: event.guideId });
1318
1339
  this.emitter.emit("track", event);
1319
1340
  return event;
1320
1341
  }
@@ -1339,14 +1360,18 @@ var GuidelyClient = class _GuidelyClient {
1339
1360
  /** Records a page view and re-evaluates triggers. Called automatically on SPA navigation. */
1340
1361
  page(url = location.href) {
1341
1362
  if (this.destroyed) return;
1342
- this.enqueue("page_view", { properties: { path: safePath(url) } });
1363
+ this.leavePage(url);
1364
+ this.trackPageView(url);
1343
1365
  this.handleNavigation(url, true);
1344
1366
  }
1345
1367
  handleNavigation(url, explicit = false) {
1346
1368
  if (this.destroyed) return;
1347
1369
  const changed = url !== this.lastUrl;
1348
1370
  this.lastUrl = url;
1349
- if (!explicit && changed) this.enqueue("page_view", { properties: { path: safePath(url) } });
1371
+ if (!explicit && changed) {
1372
+ this.leavePage(url);
1373
+ this.trackPageView(url);
1374
+ }
1350
1375
  this.completeChecklistTasksBy("url_visited", url);
1351
1376
  this.active?.controller?.resume();
1352
1377
  if (this.active?.controller) {
@@ -1356,6 +1381,11 @@ var GuidelyClient = class _GuidelyClient {
1356
1381
  if (g.config.urlPatterns.length && !g.config.urlPatterns.some((p) => urlMatches(p, url))) this.active.controller.destroy();
1357
1382
  }
1358
1383
  }
1384
+ this.widgets.forEach((w, id) => {
1385
+ if (!w.controller || w.manual) return;
1386
+ const g = this.findGuide(id);
1387
+ if (g?.config.urlPatterns.length && !g.config.urlPatterns.some((p) => urlMatches(p, url))) w.controller.destroy();
1388
+ });
1359
1389
  this.firedDelays.clear();
1360
1390
  if (this.isReady) this.evaluate("navigation");
1361
1391
  if (changed) void this.queue.flush();
@@ -1363,10 +1393,255 @@ var GuidelyClient = class _GuidelyClient {
1363
1393
  handleDocumentClick(e) {
1364
1394
  const target = e.target;
1365
1395
  if (!target || typeof target.closest !== "function") return;
1396
+ this.autocaptureClick(e, target);
1366
1397
  this.completeChecklistTasksBy("click", target);
1367
1398
  if (this.isReady) this.evaluate("click", { target });
1368
1399
  }
1369
1400
  // ---------------------------------------------------------------------------
1401
+ // Product analytics: sessions, page views, clicks, errors, session replay
1402
+ // ---------------------------------------------------------------------------
1403
+ /** Records activity; starts a new session after `sessionTimeoutMinutes` of inactivity or 24h. */
1404
+ touchSession() {
1405
+ const now = Date.now();
1406
+ const s = this.session;
1407
+ const timeout = Math.max(1, this.config?.settings?.sessionTimeoutMinutes ?? 30) * 6e4;
1408
+ const last = s.lastActivityAt ?? now;
1409
+ if (now - last > timeout || now - (Date.parse(s.startedAt) || now) > DAY_MS) return this.rotateSession(now);
1410
+ if (now - (s.lastActivityAt ?? 0) < 1e3) return;
1411
+ s.lastActivityAt = now;
1412
+ this.persistSession();
1413
+ }
1414
+ rotateSession(now) {
1415
+ this.session = newSession(now);
1416
+ this.persistSession();
1417
+ this.log("session rotated", this.session.id);
1418
+ if (this.replay?.activeSessionId) this.replay.stop();
1419
+ this.syncReplay();
1420
+ }
1421
+ /** Autocapture, error tracking and replay need analytics on, consent granted and no authoring session. */
1422
+ captureAllowed() {
1423
+ return !this.destroyed && this.analyticsEnabled() && !this.authoring?.isActive();
1424
+ }
1425
+ /** A capability is on when the project enables it (absent on old servers → off) and no option turns it off. */
1426
+ autocaptureOn(key) {
1427
+ const o = this.options.autocapture;
1428
+ return !!this.config?.settings?.autocapture?.[key] && o !== false && !(typeof o === "object" && o[key] === false) && this.captureAllowed();
1429
+ }
1430
+ /** Page views on SPA navigation predate autocapture: only an explicit opt-out stops them. */
1431
+ pageviewsOn() {
1432
+ const o = this.options.autocapture;
1433
+ return this.config?.settings?.autocapture?.pageviews !== false && o !== false && !(typeof o === "object" && o.pageviews === false) && !this.authoring?.isActive();
1434
+ }
1435
+ trackPageView(url) {
1436
+ if (url === this.pageViewUrl || !this.pageviewsOn()) return;
1437
+ this.pageViewUrl = url;
1438
+ this.touchSession();
1439
+ const collect = this.collectUrls();
1440
+ const device = detectDevice(navigator.userAgent, window.innerWidth);
1441
+ const props = { deviceType: device.type, browser: device.browser, os: device.os, screenWidth: window.screen?.width, viewportWidth: window.innerWidth };
1442
+ if (collect) {
1443
+ props.path = pathOf(url);
1444
+ props.title = (document.title || "").slice(0, 200);
1445
+ const ref = !this.session.pageViews ? externalReferrer() : void 0;
1446
+ if (ref) props.referrer = ref;
1447
+ }
1448
+ this.session.pageViews = (this.session.pageViews ?? 0) + 1;
1449
+ this.persistSession();
1450
+ this.enqueue("page_view", { url: collect ? url : void 0, properties: props });
1451
+ }
1452
+ /** Emits `page_leave` for the page being left and starts measuring `nextUrl` (null: page unload). */
1453
+ leavePage(nextUrl) {
1454
+ const t = this.pageTracker;
1455
+ if (!t.url || t.url === nextUrl) return;
1456
+ this.flushHeatmap();
1457
+ if (!this.pageLeft && this.autocaptureOn("pageLeave")) {
1458
+ const info = t.leave();
1459
+ const collect = this.collectUrls();
1460
+ this.enqueue("page_leave", { url: collect ? info.url : void 0, properties: { ...collect ? { path: pathOf(info.url) } : {}, durationMs: info.durationMs, scrollDepth: info.scrollDepth } });
1461
+ }
1462
+ if (nextUrl !== null) {
1463
+ t.start(nextUrl);
1464
+ this.pageLeft = false;
1465
+ }
1466
+ }
1467
+ autocaptureClick(e, target) {
1468
+ const clicks = this.autocaptureOn("clicks");
1469
+ const rage = this.autocaptureOn("rageClicks");
1470
+ const heat = this.autocaptureOn("heatmaps");
1471
+ if (!clicks && !rage && !heat || isIgnoredElement(target)) return;
1472
+ this.touchSession();
1473
+ const el = interactiveElement(target);
1474
+ const opts = { mask: this.maskSelector(), maskAll: this.maskAllText(), collectUrls: this.collectUrls() };
1475
+ if (clicks && el) this.enqueue("click", { properties: { ...describeClick(el, e, opts) } });
1476
+ const count = this.rage.add(e.pageX ?? 0, e.pageY ?? 0, Date.now());
1477
+ if (rage && count) this.enqueue("rage_click", { properties: { ...describeClick(el ?? target, e, opts), count } });
1478
+ if (!heat) return;
1479
+ const url = location.href;
1480
+ if (this.heatmap.url !== url) this.flushHeatmap();
1481
+ if (this.heatmap.add(url, heatmapPoint(el ?? target, e, this.rage.burst > 0), this.rage.burst)) this.flushHeatmap();
1482
+ }
1483
+ /** Bins one pointer sample into the heatmap buffer. Not session activity. */
1484
+ sampleMove(e) {
1485
+ const target = e.target;
1486
+ if (document.visibilityState === "hidden" || !target?.closest || !this.autocaptureOn("mouseMovement") || isIgnoredElement(target)) return;
1487
+ const url = location.href;
1488
+ if (this.heatmap.url !== url) this.flushHeatmap();
1489
+ if (this.heatmap.move(url, movementBin(interactiveElement(target) ?? target, e))) this.flushHeatmap();
1490
+ }
1491
+ /** Sends the buffered heatmap clicks as one `heatmap` event attributed to the page they happened on. */
1492
+ flushHeatmap() {
1493
+ const batch = this.heatmap.take();
1494
+ if (!batch || !this.captureAllowed()) return;
1495
+ const collect = this.collectUrls();
1496
+ const de = document.documentElement;
1497
+ const properties = { path: collect ? pathOf(batch.url) : void 0, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, pageHeight: Math.max(de.scrollHeight, document.body?.scrollHeight || 0), points: batch.points };
1498
+ if (!collect) delete properties.path;
1499
+ this.enqueue("heatmap", { url: collect ? batch.url : void 0, properties });
1500
+ }
1501
+ captureGlobalException(props) {
1502
+ if (!props || isGuidelyException(props) || this.options.captureErrors === false) return;
1503
+ if (!this.config) {
1504
+ if (this.earlyExceptions.length < 10) this.earlyExceptions.push([props, void 0]);
1505
+ return;
1506
+ }
1507
+ this.sendException(props);
1508
+ }
1509
+ /**
1510
+ * Reports a caught error as an `exception` event (`source: "manual"`, `handled: true`), grouped
1511
+ * into an issue on the Errors page. Extra properties are attached to the occurrence. Needs the
1512
+ * project's error tracking on (plus analytics + consent); `captureErrors: false` does not stop it.
1513
+ */
1514
+ captureException(error, properties) {
1515
+ if (this.destroyed) return;
1516
+ const props = exceptionFromError(error, "manual", true);
1517
+ if (!this.config) {
1518
+ if (this.earlyExceptions.length < 10) this.earlyExceptions.push([props, properties]);
1519
+ return;
1520
+ }
1521
+ this.sendException(props, properties);
1522
+ }
1523
+ sendException(props, extra) {
1524
+ if (!this.config?.settings?.errorTracking?.enabled || !this.captureAllowed() || !this.exceptionThrottle.allow(props)) return;
1525
+ const count = this.session.exceptions ?? 0;
1526
+ if (count >= MAX_SESSION_EXCEPTIONS) return;
1527
+ const properties = { ...props };
1528
+ if (this.options.release) properties.release = String(this.options.release).slice(0, 100);
1529
+ for (const [k, v] of Object.entries(extra ?? {})) if (!(k in properties)) properties[k] = v;
1530
+ this.enqueue("exception", { properties });
1531
+ this.session.exceptions = count + 1;
1532
+ this.persistSession();
1533
+ }
1534
+ maskSelector() {
1535
+ return validSelectors([...DEFAULT_MASK_SELECTORS, ...this.config?.settings?.maskSelectors || [], ...this.options.maskSelectors || []]).join(",");
1536
+ }
1537
+ maskAllText() {
1538
+ const o = this.options.sessionReplay;
1539
+ return !!this.config?.settings?.replay?.maskAllText || typeof o === "object" && !!o.maskAllText;
1540
+ }
1541
+ /** Replay settings for this installation, or null when the project (or an option) disables replay. */
1542
+ replayConfig() {
1543
+ const r = this.config?.settings?.replay;
1544
+ const o = this.options.sessionReplay;
1545
+ if (!r?.enabled || o === false) return null;
1546
+ const opt = typeof o === "object" ? o : {};
1547
+ const extra = opt.blockSelectors || [];
1548
+ return {
1549
+ sampleRate: r.sampleRate ?? 100,
1550
+ config: {
1551
+ recordConsole: r.recordConsole !== false,
1552
+ maskTextSelector: this.maskAllText() ? "*" : this.maskSelector(),
1553
+ blockSelector: validSelectors(["[data-guidely-block]", ...r.blockSelectors || [], ...extra]).join(","),
1554
+ network: r.recordNetwork && opt.recordNetwork !== false ? { payloads: !!r.recordNetworkPayloads && opt.recordNetworkPayloads !== false, apiUrl: this.options.apiUrl, mask: opt.maskNetworkRequest } : null
1555
+ }
1556
+ };
1557
+ }
1558
+ /** Reconciles capture with the current config / consent / authoring state. Idempotent. */
1559
+ syncCapture() {
1560
+ if (!this.isReady || this.destroyed) return;
1561
+ if (this.config && !this.initialPageViewSent && this.config.settings?.autocapture) {
1562
+ this.initialPageViewSent = true;
1563
+ this.trackPageView(location.href);
1564
+ }
1565
+ if (this.config) {
1566
+ for (const [props, extra] of this.earlyExceptions.splice(0)) {
1567
+ if (props.handled) this.sendException(props, extra);
1568
+ else this.captureGlobalException(props);
1569
+ }
1570
+ }
1571
+ this.syncReplay();
1572
+ }
1573
+ syncReplay() {
1574
+ const cfg = this.isReady && this.captureAllowed() ? this.replayConfig() : null;
1575
+ if (cfg && this.session.replay === void 0) {
1576
+ this.session.replay = rolloutBucket(this.session.id, "replay") < cfg.sampleRate;
1577
+ this.persistSession();
1578
+ }
1579
+ if (!cfg || !this.session.replay) {
1580
+ if (this.replay?.activeSessionId) this.replay.stop({ discard: !this.analyticsEnabled() });
1581
+ return;
1582
+ }
1583
+ if (this.replay) return this.replay.start(this.session.id, cfg.config);
1584
+ if (this.replayLoading) return;
1585
+ this.replayLoading = true;
1586
+ loadRecorder().then(
1587
+ (api) => {
1588
+ this.replayLoading = false;
1589
+ if (this.destroyed || this.replay) return;
1590
+ this.replay = api.createReplay({
1591
+ url: (params) => this.api.url("/replay", params),
1592
+ fetch: () => this.options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : null),
1593
+ meta: () => {
1594
+ const device = detectDevice(navigator.userAgent, window.innerWidth);
1595
+ return {
1596
+ anonymousId: this.anonymousId,
1597
+ userId: this.user?.id || null,
1598
+ url: this.collectUrls() ? location.href : void 0,
1599
+ device: { type: device.type, browser: device.browser, os: device.os },
1600
+ viewport: { width: window.innerWidth, height: window.innerHeight },
1601
+ sdk: { name: SDK_NAME, version: SDK_VERSION }
1602
+ };
1603
+ },
1604
+ onDisabled: (sessionId) => {
1605
+ if (sessionId !== this.session.id) return;
1606
+ this.session.replay = false;
1607
+ this.persistSession();
1608
+ this.replay?.stop({ discard: true });
1609
+ },
1610
+ onError: (err) => this.reportError(err)
1611
+ });
1612
+ this.syncReplay();
1613
+ },
1614
+ (err) => {
1615
+ this.replayLoading = false;
1616
+ this.reportError(err);
1617
+ }
1618
+ );
1619
+ }
1620
+ /**
1621
+ * Records this session even when it was not sampled, provided the project has session replay
1622
+ * enabled (and analytics + consent allow it). The decision sticks for the rest of the session.
1623
+ */
1624
+ startSessionRecording() {
1625
+ if (this.destroyed) return;
1626
+ this.session.replay = true;
1627
+ this.persistSession();
1628
+ this.syncReplay();
1629
+ }
1630
+ /** Stops recording for the rest of this session (what was recorded so far is uploaded). */
1631
+ stopSessionRecording() {
1632
+ this.session.replay = false;
1633
+ this.persistSession();
1634
+ this.replay?.stop();
1635
+ }
1636
+ /** True while the session is being recorded. */
1637
+ isRecording() {
1638
+ return !!this.replay?.isRecording();
1639
+ }
1640
+ /** The current analytics session id (rotates after inactivity; also the replay id). */
1641
+ getSessionId() {
1642
+ return this.session.id;
1643
+ }
1644
+ // ---------------------------------------------------------------------------
1370
1645
  // Targeting + triggers
1371
1646
  // ---------------------------------------------------------------------------
1372
1647
  /** Builds the targeting context for the current moment. */
@@ -1387,7 +1662,8 @@ var GuidelyClient = class _GuidelyClient {
1387
1662
  seenGuides: keysOf(Object.keys(guides).filter((id) => (guides[id]?.impressions ?? 0) > 0)),
1388
1663
  completedChecklists: keysOf(completedChecklists),
1389
1664
  audienceIds: this.state.audienceIds,
1390
- environment: this.options.environment
1665
+ environment: this.options.environment,
1666
+ platform: "web"
1391
1667
  });
1392
1668
  }
1393
1669
  isTargeted(guide, ctx, url) {
@@ -1412,6 +1688,7 @@ var GuidelyClient = class _GuidelyClient {
1412
1688
  const t = guide.config.trigger;
1413
1689
  switch (t.type) {
1414
1690
  case "page_load":
1691
+ if (t.delayMs > 0) return reason === "timer" && detail.guideId === guide.id;
1415
1692
  return reason === "ready" || reason === "navigation" || reason === "identify";
1416
1693
  case "url_match":
1417
1694
  return (reason === "ready" || reason === "navigation" || reason === "identify") && urlMatches(t.value, location.href);
@@ -1632,7 +1909,7 @@ var GuidelyClient = class _GuidelyClient {
1632
1909
  });
1633
1910
  entry.controller = controller;
1634
1911
  if (exclusive) this.active = entry;
1635
- else this.widgets.set(guide.id, { destroy: () => controller.destroy(), controller });
1912
+ else this.widgets.set(guide.id, { destroy: () => controller.destroy(), controller, manual });
1636
1913
  controller.start();
1637
1914
  if (controller.getState().status === "ended") {
1638
1915
  if (exclusive && this.active === entry) this.active = null;
@@ -2026,6 +2303,8 @@ var GuidelyClient = class _GuidelyClient {
2026
2303
  onError: (err) => this.reportError(err),
2027
2304
  onActive: (active) => {
2028
2305
  this.emitter.emit("authoring", { active });
2306
+ if (active) this.replay?.stop({ discard: true });
2307
+ else this.syncCapture();
2029
2308
  if (active) {
2030
2309
  this.stopGuide();
2031
2310
  this.widgets.forEach((w) => w.destroy());
@@ -2076,12 +2355,23 @@ var GuidelyClient = class _GuidelyClient {
2076
2355
  }
2077
2356
  };
2078
2357
  var current = null;
2079
- function safePath(url) {
2358
+ function webOnly(config) {
2359
+ return config.guides.every((g) => supportsPlatform(g.config, "web")) ? config : { ...config, guides: config.guides.filter((g) => supportsPlatform(g.config, "web")) };
2360
+ }
2361
+ function pathOf(url) {
2362
+ try {
2363
+ return new URL(url, "http://localhost").pathname;
2364
+ } catch {
2365
+ return url.replace(/[?#].*$/, "");
2366
+ }
2367
+ }
2368
+ function externalReferrer() {
2080
2369
  try {
2081
- const u = new URL(url, "http://localhost");
2082
- return u.pathname + u.search;
2370
+ if (!document.referrer) return void 0;
2371
+ const u = new URL(document.referrer);
2372
+ return u.origin === location.origin ? void 0 : u.origin + u.pathname;
2083
2373
  } catch {
2084
- return url;
2374
+ return void 0;
2085
2375
  }
2086
2376
  }
2087
2377
  function createGuidely(options) {