@guidelytech/sdk-core 0.2.1 → 0.4.1

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.cjs CHANGED
@@ -56,9 +56,9 @@ __export(index_exports, {
56
56
  module.exports = __toCommonJS(index_exports);
57
57
 
58
58
  // src/client.ts
59
- var import_runtime6 = require("@guidelytech/shared/runtime");
60
- var import_guide_runtime2 = require("@guidelytech/guide-runtime");
59
+ var import_runtime11 = require("@guidelytech/shared/runtime");
61
60
  var import_guide_runtime3 = require("@guidelytech/guide-runtime");
61
+ var import_guide_runtime4 = require("@guidelytech/guide-runtime");
62
62
 
63
63
  // src/authoring.ts
64
64
  var import_guide_runtime = require("@guidelytech/guide-runtime");
@@ -105,9 +105,9 @@ function captureSnapshot(doc, maskSelectors, maxBytes = MAX_SNAPSHOT_BYTES) {
105
105
  }
106
106
  }
107
107
  clone.querySelectorAll("*").forEach((el) => {
108
- for (const attr of Array.from(el.attributes)) {
109
- const name = attr.name.toLowerCase();
110
- if (name.startsWith("on") || (name === "href" || name === "src" || name === "action") && /^\s*javascript:/i.test(attr.value)) el.removeAttribute(attr.name);
108
+ for (const attr2 of Array.from(el.attributes)) {
109
+ const name = attr2.name.toLowerCase();
110
+ if (name.startsWith("on") || (name === "href" || name === "src" || name === "action") && /^\s*javascript:/i.test(attr2.value)) el.removeAttribute(attr2.name);
111
111
  }
112
112
  if (el instanceof HTMLInputElement) {
113
113
  el.removeAttribute("value");
@@ -440,6 +440,134 @@ var AuthoringBridge = class {
440
440
  }
441
441
  };
442
442
 
443
+ // src/autocapture.ts
444
+ var import_guide_runtime2 = require("@guidelytech/guide-runtime");
445
+ 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]";
446
+ var DEFAULT_MASK_SELECTORS = ["[data-guidely-mask]", ".guidely-mask"];
447
+ var PREFERRED_ATTRS = ["data-testid", "data-test-id", "data-test", "data-cy", "data-qa", "data-guidely-track", "data-tour"];
448
+ 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;
449
+ function validSelectors(list) {
450
+ const out = [];
451
+ const probe = typeof document !== "undefined" ? document.createDocumentFragment() : null;
452
+ for (const raw of list) {
453
+ const s = (raw || "").trim();
454
+ if (!s || out.includes(s)) continue;
455
+ try {
456
+ probe?.querySelector(s);
457
+ out.push(s);
458
+ } catch {
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+ function isIgnoredElement(el) {
464
+ return (0, import_guide_runtime2.isGuidelyElement)(el) || !!el.closest("[data-guidely-ignore]");
465
+ }
466
+ function interactiveElement(target) {
467
+ try {
468
+ return target.closest(INTERACTIVE_SELECTOR);
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+ function esc(value) {
474
+ const g = globalThis;
475
+ return g.CSS?.escape ? g.CSS.escape(value) : value.replace(/[^\w-]/g, (c) => `\\${c}`);
476
+ }
477
+ var attr = (tag, name, value) => value ? `${tag}[${name}="${value.replace(/["\\]/g, "\\$&")}"]` : `${tag}[${name}]`;
478
+ function elementSelector(el) {
479
+ const tag = el.tagName.toLowerCase();
480
+ if (el.id && (0, import_guide_runtime2.isStableId)(el.id)) return `#${esc(el.id)}`;
481
+ for (const name of PREFERRED_ATTRS) if (el.hasAttribute(name)) return attr(tag, name, el.getAttribute(name) || "").slice(0, 300);
482
+ for (const a of Array.from(el.attributes)) {
483
+ 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);
484
+ }
485
+ const label = el.getAttribute("aria-label");
486
+ if (label && label.length <= 80) return attr(tag, "aria-label", label).slice(0, 300);
487
+ const parts = [];
488
+ let cur = el;
489
+ while (cur && parts.length < 4) {
490
+ const t = cur.tagName.toLowerCase();
491
+ if (t === "html" || t === "body") break;
492
+ if (cur !== el && cur.id && (0, import_guide_runtime2.isStableId)(cur.id)) {
493
+ parts.unshift(`#${esc(cur.id)}`);
494
+ break;
495
+ }
496
+ const parent = cur.parentElement;
497
+ const node = cur;
498
+ const same = parent ? Array.from(parent.children).filter((c) => c.tagName === node.tagName) : [];
499
+ parts.unshift(same.length > 1 ? `${t}:nth-of-type(${same.indexOf(node) + 1})` : t);
500
+ cur = parent;
501
+ }
502
+ return (parts.join(" > ") || tag).slice(0, 300);
503
+ }
504
+ function matches(el, selector, deep) {
505
+ if (!selector) return false;
506
+ try {
507
+ return !!el.closest(selector) || deep && !!el.querySelector(selector);
508
+ } catch {
509
+ return false;
510
+ }
511
+ }
512
+ function elementText(el, mask, maskAll) {
513
+ if (maskAll || matches(el, mask, true)) return void 0;
514
+ const tag = el.tagName.toLowerCase();
515
+ let text = "";
516
+ if (tag === "input") {
517
+ if (!/^(button|submit|reset)$/i.test(el.type || "")) return void 0;
518
+ text = el.value;
519
+ } else if (tag === "select" || tag === "textarea") return void 0;
520
+ else text = el.innerText ?? el.textContent ?? "";
521
+ text = (text || el.getAttribute("aria-label") || el.getAttribute("title") || "").replace(/\s+/g, " ").trim();
522
+ return text ? text.slice(0, 100) : void 0;
523
+ }
524
+ function linkHref(el) {
525
+ const a = el.closest("a[href]");
526
+ if (!a) return void 0;
527
+ try {
528
+ const u = new URL(a.getAttribute("href") || "", location.href);
529
+ if (!/^https?:$/.test(u.protocol)) return void 0;
530
+ return u.origin === location.origin ? u.pathname : u.origin;
531
+ } catch {
532
+ return void 0;
533
+ }
534
+ }
535
+ function describeClick(el, e, opts) {
536
+ const props = { tag: el.tagName.toLowerCase(), selector: elementSelector(el), x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
537
+ const text = elementText(el, opts.mask, opts.maskAll);
538
+ if (text) props.text = text;
539
+ const href = opts.collectUrls ? linkHref(el) : void 0;
540
+ if (href) props.href = href;
541
+ return props;
542
+ }
543
+ var RageClickDetector = class {
544
+ constructor(threshold = 3, windowMs = 1e3, radius = 30) {
545
+ __publicField(this, "threshold", threshold);
546
+ __publicField(this, "windowMs", windowMs);
547
+ __publicField(this, "radius", radius);
548
+ __publicField(this, "clicks", []);
549
+ __publicField(this, "fired", false);
550
+ }
551
+ /** Clicks in the current burst once it has fired (rage), otherwise 0. */
552
+ get burst() {
553
+ return this.fired ? this.clicks.length : 0;
554
+ }
555
+ /** Records a click; returns the click count when it completes a burst, otherwise 0. */
556
+ add(x, y, t) {
557
+ const first = this.clicks[0];
558
+ const last = this.clicks[this.clicks.length - 1];
559
+ if (!first || !last || t - last.t > this.windowMs || Math.hypot(x - first.x, y - first.y) > this.radius) {
560
+ this.clicks = [];
561
+ this.fired = false;
562
+ }
563
+ this.clicks = this.clicks.filter((c) => t - c.t <= this.windowMs);
564
+ this.clicks.push({ x, y, t });
565
+ if (this.fired || this.clicks.length < this.threshold) return 0;
566
+ this.fired = true;
567
+ return this.clicks.length;
568
+ }
569
+ };
570
+
443
571
  // src/context.ts
444
572
  function detectDevice(ua, width) {
445
573
  const u = ua || "";
@@ -496,9 +624,153 @@ function buildContext(input) {
496
624
  audienceIds: new Set(input.audienceIds),
497
625
  now: input.now ?? /* @__PURE__ */ new Date(),
498
626
  environment: input.environment,
499
- locale: input.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0)
627
+ locale: input.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0),
628
+ platform: input.platform ?? "web",
629
+ ...input.screen !== void 0 ? { screen: input.screen } : {},
630
+ ...input.appVersion !== void 0 ? { appVersion: input.appVersion } : {}
631
+ };
632
+ }
633
+
634
+ // src/heatmap.ts
635
+ var HEATMAP_MAX_POINTS = 40;
636
+ var HEATMAP_MAX_BYTES = 6e3;
637
+ var HEATMAP_MAX_BINS = 60;
638
+ var r2 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 100) / 100;
639
+ function heatmapPoint(el, e, rage) {
640
+ const p = { k: rage ? "r" : "c", x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
641
+ const s = elementSelector(el).slice(0, 120);
642
+ if (s) p.s = s;
643
+ const rect = el.getBoundingClientRect();
644
+ if (rect.width > 0 && rect.height > 0) {
645
+ p.rx = r2((e.clientX - rect.left) / rect.width);
646
+ p.ry = r2((e.clientY - rect.top) / rect.height);
647
+ }
648
+ return p;
649
+ }
650
+ var selectors = /* @__PURE__ */ new WeakMap();
651
+ var r1 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 10) / 10;
652
+ function movementBin(el, e) {
653
+ const p = { k: "m", x: Math.round(e.pageX ?? 0) || 0, y: Math.round(e.pageY ?? 0) || 0 };
654
+ let s = /^(HTML|BODY)$/.test(el.tagName) ? "" : selectors.get(el);
655
+ if (s === void 0) selectors.set(el, s = elementSelector(el).slice(0, 120));
656
+ const rect = s ? el.getBoundingClientRect() : null;
657
+ if (!rect || !(rect.width > 0 && rect.height > 0)) return [`${p.x >> 4},${p.y >> 4}`, p];
658
+ p.s = s;
659
+ p.rx = r1((e.clientX - rect.left) / rect.width);
660
+ p.ry = r1((e.clientY - rect.top) / rect.height);
661
+ return [`${s} ${p.rx},${p.ry}`, p];
662
+ }
663
+ var HeatmapBuffer = class {
664
+ constructor() {
665
+ /** The page the buffered points belong to. */
666
+ __publicField(this, "url", "");
667
+ __publicField(this, "points", []);
668
+ __publicField(this, "bins", /* @__PURE__ */ new Map());
669
+ __publicField(this, "bytes", 0);
670
+ }
671
+ /** Adds a click (`burst` > 0 marks it and the burst's previous clicks as rage); true when the batch is full. */
672
+ add(url, point, burst) {
673
+ this.url = url;
674
+ for (let i = Math.max(0, this.points.length - burst + 1); i < this.points.length; i++) this.points[i].k = "r";
675
+ this.points.push(point);
676
+ this.bytes += JSON.stringify(point).length + 1;
677
+ return this.full();
678
+ }
679
+ /** Adds a movement sample to its bin (weight +1); true when the batch is full. */
680
+ move(url, [key, point]) {
681
+ this.url = url;
682
+ const bin = this.bins.get(key);
683
+ if (bin) bin.w = (bin.w ?? 1) + 1;
684
+ else {
685
+ this.bins.set(key, point);
686
+ this.bytes += JSON.stringify(point).length + 8;
687
+ }
688
+ return this.full();
689
+ }
690
+ full() {
691
+ return this.points.length >= HEATMAP_MAX_POINTS || this.bins.size > HEATMAP_MAX_BINS || this.bytes >= HEATMAP_MAX_BYTES;
692
+ }
693
+ /** Empties the buffer, returning what was in it (clicks first, then movement bins). */
694
+ take() {
695
+ const points = [...this.points, ...this.bins.values()];
696
+ this.points = [];
697
+ this.bins = /* @__PURE__ */ new Map();
698
+ this.bytes = 0;
699
+ return points.length ? { url: this.url, points } : null;
700
+ }
701
+ };
702
+
703
+ // src/errors.ts
704
+ var import_runtime4 = require("@guidelytech/shared/runtime");
705
+ var MAX_FRAMES_BYTES = 6e3;
706
+ function fitFrames(frames) {
707
+ const out = frames.slice(0, 30);
708
+ while (out.length > 1 && JSON.stringify(out).length > MAX_FRAMES_BYTES) out.pop();
709
+ return out;
710
+ }
711
+ function describe(value) {
712
+ if (typeof value === "string") return value;
713
+ try {
714
+ return JSON.stringify(value) ?? String(value);
715
+ } catch {
716
+ return String(value);
717
+ }
718
+ }
719
+ function exceptionFromError(error, source, handled, fallbackType = "Error") {
720
+ const isError = error instanceof Error || !!error && typeof error === "object" && typeof error.message === "string" && typeof error.stack === "string";
721
+ const e = error;
722
+ return {
723
+ type: isError && e.name || fallbackType,
724
+ message: (isError ? e.message : describe(error)).slice(0, 1e3),
725
+ frames: isError ? fitFrames((0, import_runtime4.parseStack)(e.stack)) : [],
726
+ source,
727
+ handled
500
728
  };
501
729
  }
730
+ function errorEventToException(e) {
731
+ const target = e.target;
732
+ if (target && typeof Element !== "undefined" && target instanceof Element) return null;
733
+ if (e.error != null) return exceptionFromError(e.error, "onerror", false);
734
+ const message = e.message || "";
735
+ if (!message || /^script error\.?$/i.test(message.trim())) return null;
736
+ const m = /^(?:Uncaught )?([A-Z]\w*(?:Error|Exception)):\s*([\s\S]*)$/.exec(message);
737
+ return {
738
+ type: m?.[1] ?? "Error",
739
+ message: (m?.[2] ?? message).slice(0, 1e3),
740
+ frames: e.filename ? [{ file: e.filename.slice(0, 500), line: e.lineno || void 0, col: e.colno || void 0 }] : [],
741
+ source: "onerror",
742
+ handled: false
743
+ };
744
+ }
745
+ function rejectionToException(e) {
746
+ return exceptionFromError(e.reason, "unhandledrejection", false, "UnhandledRejection");
747
+ }
748
+ var GUIDELY_SCRIPT = /\/guidely(-recorder)?(\.min)?\.js$/i;
749
+ function isGuidelyException(props) {
750
+ if (props.message.startsWith("[Guidely]")) return true;
751
+ const file = props.frames[0]?.file;
752
+ if (!file) return false;
753
+ try {
754
+ return GUIDELY_SCRIPT.test(new URL(file).pathname);
755
+ } catch {
756
+ return GUIDELY_SCRIPT.test(file.replace(/[?#].*$/, ""));
757
+ }
758
+ }
759
+ var ExceptionThrottle = class {
760
+ constructor(max = 5) {
761
+ __publicField(this, "max", max);
762
+ __publicField(this, "seen", /* @__PURE__ */ new Map());
763
+ }
764
+ allow(props) {
765
+ const top = props.frames[0];
766
+ const key = `${props.type}|${props.message}|${top ? `${top.file}:${top.line}:${top.col}` : ""}`;
767
+ const n = this.seen.get(key) ?? 0;
768
+ if (n >= this.max) return false;
769
+ if (this.seen.size > 500) this.seen.clear();
770
+ this.seen.set(key, n + 1);
771
+ return true;
772
+ }
773
+ };
502
774
 
503
775
  // src/emitter.ts
504
776
  var Emitter = class {
@@ -586,10 +858,8 @@ var Api = class {
586
858
  __publicField(this, "fetchImpl", fetchImpl);
587
859
  }
588
860
  url(path, params = {}) {
589
- const u = new URL(`${this.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}`);
590
- u.searchParams.set("key", this.key);
591
- for (const [k, v] of Object.entries(params)) if (v != null) u.searchParams.set(k, v);
592
- return u.toString();
861
+ const query = Object.entries({ key: this.key, ...params }).filter((e) => e[1] != null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
862
+ return `${this.baseUrl.replace(/\/$/, "")}/api/v1/sdk${path}?${query}`;
593
863
  }
594
864
  get fetcher() {
595
865
  if (this.fetchImpl) return this.fetchImpl;
@@ -878,6 +1148,51 @@ function watchNavigation(listener, debounceMs = 50) {
878
1148
  };
879
1149
  }
880
1150
 
1151
+ // src/page-tracking.ts
1152
+ function scrollDepth() {
1153
+ const de = document.documentElement;
1154
+ const height = Math.max(de?.scrollHeight || 0, document.body?.scrollHeight || 0);
1155
+ const view = window.innerHeight || de?.clientHeight || 0;
1156
+ if (height <= 0 || height <= view) return 100;
1157
+ const y = window.scrollY || de?.scrollTop || 0;
1158
+ return Math.max(0, Math.min(100, Math.round((y + view) / height * 100)));
1159
+ }
1160
+ var PageTracker = class {
1161
+ constructor() {
1162
+ __publicField(this, "url", "");
1163
+ __publicField(this, "visibleMs", 0);
1164
+ __publicField(this, "visibleSince", null);
1165
+ __publicField(this, "maxScroll", 0);
1166
+ }
1167
+ /** Starts measuring a new page. */
1168
+ start(url, now = Date.now()) {
1169
+ this.url = url;
1170
+ this.visibleMs = 0;
1171
+ this.visibleSince = typeof document !== "undefined" && document.visibilityState === "hidden" ? null : now;
1172
+ this.maxScroll = 0;
1173
+ this.measureScroll();
1174
+ }
1175
+ /** Pauses / resumes the visible-time clock. */
1176
+ setHidden(hidden, now = Date.now()) {
1177
+ if (hidden && this.visibleSince !== null) {
1178
+ this.visibleMs += Math.max(0, now - this.visibleSince);
1179
+ this.visibleSince = null;
1180
+ } else if (!hidden && this.visibleSince === null) this.visibleSince = now;
1181
+ }
1182
+ measureScroll() {
1183
+ try {
1184
+ this.maxScroll = Math.max(this.maxScroll, scrollDepth());
1185
+ } catch {
1186
+ }
1187
+ }
1188
+ /** Final numbers for the current page. */
1189
+ leave(now = Date.now()) {
1190
+ this.measureScroll();
1191
+ const running = this.visibleSince !== null ? Math.max(0, now - this.visibleSince) : 0;
1192
+ return { url: this.url, durationMs: Math.round(this.visibleMs + running), scrollDepth: this.maxScroll };
1193
+ }
1194
+ };
1195
+
881
1196
  // src/queue.ts
882
1197
  var EventQueue = class {
883
1198
  constructor(opts) {
@@ -991,11 +1306,16 @@ var EventQueue = class {
991
1306
  }
992
1307
  };
993
1308
 
1309
+ // src/load-recorder.ts
1310
+ var loadRecorder = async () => (await import("./recorder.cjs")).recorder;
1311
+
994
1312
  // src/client.ts
995
1313
  var SDK_NAME = "@guidelytech/sdk-core";
996
- var SDK_VERSION = "0.2.1";
1314
+ var SDK_VERSION = "0.4.1";
997
1315
  var DEFAULT_API_URL = "https://api.guidely.app";
998
1316
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
1317
+ var DAY_MS = 24 * 60 * 60 * 1e3;
1318
+ var MAX_SESSION_EXCEPTIONS = 100;
999
1319
  function isBrowser() {
1000
1320
  return typeof window !== "undefined" && typeof document !== "undefined";
1001
1321
  }
@@ -1005,6 +1325,9 @@ function nowIso() {
1005
1325
  function emptyState() {
1006
1326
  return { guides: {}, checklists: {}, surveysSubmitted: [], audienceIds: [] };
1007
1327
  }
1328
+ function newSession(now = Date.now()) {
1329
+ return { id: `s_${(0, import_runtime11.randomId)(16)}`, startedAt: new Date(now).toISOString(), shownGuides: [], events: [], lastActivityAt: now };
1330
+ }
1008
1331
  var GuidelyClient = class _GuidelyClient {
1009
1332
  constructor(options) {
1010
1333
  __publicField(this, "options");
@@ -1042,6 +1365,19 @@ var GuidelyClient = class _GuidelyClient {
1042
1365
  __publicField(this, "lastAudiences", /* @__PURE__ */ new Set());
1043
1366
  /** Set once the app calls identify()/reset(); bootstrap then leaves identity alone instead of re-identifying. */
1044
1367
  __publicField(this, "identitySettled", false);
1368
+ // Product analytics (autocapture, error tracking, session replay)
1369
+ /** Created once the recorder chunk has loaded (it carries the replay controller). */
1370
+ __publicField(this, "replay", null);
1371
+ __publicField(this, "replayLoading", false);
1372
+ __publicField(this, "pageTracker", new PageTracker());
1373
+ __publicField(this, "rage", new RageClickDetector());
1374
+ __publicField(this, "heatmap", new HeatmapBuffer());
1375
+ __publicField(this, "exceptionThrottle", new ExceptionThrottle());
1376
+ /** Exceptions seen before the config was loaded (reported once it is known whether the server accepts them). */
1377
+ __publicField(this, "earlyExceptions", []);
1378
+ __publicField(this, "initialPageViewSent", false);
1379
+ __publicField(this, "pageViewUrl", null);
1380
+ __publicField(this, "pageLeft", false);
1045
1381
  __publicField(this, "customListeners", /* @__PURE__ */ new Map());
1046
1382
  __publicField(this, "eventBus", {
1047
1383
  on: (name, cb) => {
@@ -1076,7 +1412,7 @@ var GuidelyClient = class _GuidelyClient {
1076
1412
  this.sessionStore = this.options.storage === "memory" ? this.store : createStorage("session");
1077
1413
  this.anonymousId = this.store.get(KEYS.anon) || "";
1078
1414
  if (!this.anonymousId) {
1079
- this.anonymousId = `anon_${(0, import_runtime6.randomId)(20)}`;
1415
+ this.anonymousId = `anon_${(0, import_runtime11.randomId)(20)}`;
1080
1416
  this.store.set(KEYS.anon, this.anonymousId);
1081
1417
  }
1082
1418
  this.firstVisit = !this.store.get(KEYS.seen);
@@ -1084,7 +1420,7 @@ var GuidelyClient = class _GuidelyClient {
1084
1420
  this.state = { ...emptyState(), ...readJson(this.store, KEYS.state(this.options.projectId), {}) };
1085
1421
  this.user = readJson(this.store, KEYS.user(this.options.projectId), null);
1086
1422
  const session = readJson(this.sessionStore, KEYS.session, null);
1087
- this.session = session && session.id ? session : { id: `s_${(0, import_runtime6.randomId)(16)}`, startedAt: nowIso(), shownGuides: [], events: [] };
1423
+ this.session = session && session.id ? session : newSession();
1088
1424
  this.persistSession();
1089
1425
  this.lastUrl = location.href;
1090
1426
  this.lastAudiences = new Set(this.state.audienceIds);
@@ -1118,10 +1454,11 @@ var GuidelyClient = class _GuidelyClient {
1118
1454
  return this.init();
1119
1455
  }
1120
1456
  async bootstrap() {
1457
+ this.wireErrorListeners();
1121
1458
  try {
1122
1459
  const cached = readJson(this.store, KEYS.config(this.options.projectId), null);
1123
1460
  this.etag = this.store.get(KEYS.etag(this.options.projectId));
1124
- if (cached && cached.guides) this.config = cached;
1461
+ if (cached && cached.guides) this.config = webOnly(cached);
1125
1462
  await this.fetchConfig();
1126
1463
  if (this.destroyed) return;
1127
1464
  this.wireListeners();
@@ -1137,6 +1474,9 @@ var GuidelyClient = class _GuidelyClient {
1137
1474
  this.resumeActiveGuide();
1138
1475
  this.evaluate("ready");
1139
1476
  if (!this.options.disableAuthoring) this.maybeStartAuthoring();
1477
+ this.pageTracker.start(location.href);
1478
+ this.touchSession();
1479
+ this.syncCapture();
1140
1480
  } catch (err) {
1141
1481
  this.reportError(err);
1142
1482
  this.isReady = true;
@@ -1145,10 +1485,10 @@ var GuidelyClient = class _GuidelyClient {
1145
1485
  }
1146
1486
  async fetchConfig() {
1147
1487
  try {
1148
- const res = await this.api.get("/config", { etag: this.etag });
1488
+ const res = await this.api.get("/config", { etag: this.etag, params: { platform: "web" } });
1149
1489
  if (res.status === 304) return true;
1150
1490
  if (res.ok && res.data && Array.isArray(res.data.guides)) {
1151
- this.config = res.data;
1491
+ this.config = webOnly(res.data);
1152
1492
  this.etag = res.etag;
1153
1493
  writeJson(this.store, KEYS.config(this.options.projectId), res.data);
1154
1494
  if (res.etag) this.store.set(KEYS.etag(this.options.projectId), res.etag);
@@ -1171,14 +1511,62 @@ var GuidelyClient = class _GuidelyClient {
1171
1511
  const onClick = (e) => this.handleDocumentClick(e);
1172
1512
  doc.addEventListener("click", onClick, true);
1173
1513
  this.cleanups.push(() => doc.removeEventListener("click", onClick, true));
1174
- const onHide = () => {
1175
- if (doc.visibilityState === "hidden") this.queue.flushSync();
1514
+ const onVisibility = () => {
1515
+ const hidden = doc.visibilityState === "hidden";
1516
+ this.pageTracker.setHidden(hidden);
1517
+ if (!hidden) return;
1518
+ this.replay?.flushOnHide();
1519
+ this.queue.flushSync();
1520
+ };
1521
+ const onPageHide = () => {
1522
+ this.flushHeatmap();
1523
+ if (!this.pageLeft) this.leavePage(null);
1524
+ this.pageLeft = true;
1525
+ this.replay?.flushOnHide();
1526
+ this.queue.flushSync();
1527
+ };
1528
+ const onPageShow = (e) => {
1529
+ if (!e.persisted || !this.pageLeft) return;
1530
+ this.pageLeft = false;
1531
+ this.pageTracker.start(location.href);
1176
1532
  };
1177
- doc.addEventListener("visibilitychange", onHide);
1178
- win.addEventListener("pagehide", onHide);
1533
+ let lastScroll = 0;
1534
+ const onScroll = () => {
1535
+ const now = Date.now();
1536
+ if (now - lastScroll < 250) return;
1537
+ lastScroll = now;
1538
+ this.pageTracker.measureScroll();
1539
+ this.touchSession();
1540
+ };
1541
+ const onKey = () => this.touchSession();
1542
+ let move = null;
1543
+ let moveTimer = null;
1544
+ const onMove = (e) => {
1545
+ move = e;
1546
+ moveTimer ?? (moveTimer = setTimeout(() => {
1547
+ moveTimer = null;
1548
+ if (move) this.sampleMove(move);
1549
+ move = null;
1550
+ }, 200));
1551
+ };
1552
+ if (win.matchMedia?.("(pointer: fine)").matches) {
1553
+ doc.addEventListener("mousemove", onMove, { passive: true });
1554
+ this.cleanups.push(() => {
1555
+ doc.removeEventListener("mousemove", onMove);
1556
+ if (moveTimer) clearTimeout(moveTimer);
1557
+ });
1558
+ }
1559
+ doc.addEventListener("visibilitychange", onVisibility);
1560
+ win.addEventListener("pagehide", onPageHide);
1561
+ win.addEventListener("pageshow", onPageShow);
1562
+ win.addEventListener("scroll", onScroll, { passive: true });
1563
+ doc.addEventListener("keydown", onKey, true);
1179
1564
  this.cleanups.push(() => {
1180
- doc.removeEventListener("visibilitychange", onHide);
1181
- win.removeEventListener("pagehide", onHide);
1565
+ doc.removeEventListener("visibilitychange", onVisibility);
1566
+ win.removeEventListener("pagehide", onPageHide);
1567
+ win.removeEventListener("pageshow", onPageShow);
1568
+ win.removeEventListener("scroll", onScroll);
1569
+ doc.removeEventListener("keydown", onKey, true);
1182
1570
  });
1183
1571
  const refresh = setInterval(() => void this.refresh(), this.options.configRefreshMs);
1184
1572
  this.cleanups.push(() => clearInterval(refresh));
@@ -1196,6 +1584,18 @@ var GuidelyClient = class _GuidelyClient {
1196
1584
  win.addEventListener("message", onMessage);
1197
1585
  this.cleanups.push(() => win.removeEventListener("message", onMessage));
1198
1586
  }
1587
+ /** Uncaught errors + unhandled rejections; attached before the config loads so boot errors are kept. */
1588
+ wireErrorListeners() {
1589
+ const win = window;
1590
+ const onError = (e) => this.captureGlobalException(errorEventToException(e));
1591
+ const onRejection = (e) => this.captureGlobalException(rejectionToException(e));
1592
+ win.addEventListener("error", onError);
1593
+ win.addEventListener("unhandledrejection", onRejection);
1594
+ this.cleanups.push(() => {
1595
+ win.removeEventListener("error", onError);
1596
+ win.removeEventListener("unhandledrejection", onRejection);
1597
+ });
1598
+ }
1199
1599
  /** Re-fetches the published config and re-evaluates triggers. */
1200
1600
  async refresh() {
1201
1601
  if (this.destroyed) return;
@@ -1205,6 +1605,7 @@ var GuidelyClient = class _GuidelyClient {
1205
1605
  this.renderPersistentWidgets();
1206
1606
  this.evaluate("ready");
1207
1607
  }
1608
+ this.syncCapture();
1208
1609
  }
1209
1610
  /** Tears everything down: guides, widgets, listeners, timers, host element. Re-init is allowed via a new client. */
1210
1611
  destroy() {
@@ -1228,10 +1629,12 @@ var GuidelyClient = class _GuidelyClient {
1228
1629
  this.cleanups.splice(0).forEach((fn) => fn());
1229
1630
  this.authoring?.stop();
1230
1631
  this.authoring = null;
1632
+ this.replay?.destroy();
1633
+ this.replay = null;
1231
1634
  this.queue.flushSync();
1232
1635
  this.queue.destroy();
1233
1636
  this.emitter.clear();
1234
- (0, import_guide_runtime2.destroyHost)();
1637
+ (0, import_guide_runtime3.destroyHost)();
1235
1638
  if (current === this) current = null;
1236
1639
  }
1237
1640
  // ---------------------------------------------------------------------------
@@ -1243,7 +1646,7 @@ var GuidelyClient = class _GuidelyClient {
1243
1646
  this.identitySettled = true;
1244
1647
  this.user = { id: String(userId), traits: { ...this.user?.id === String(userId) ? this.user.traits : {}, ...traits || {} }, account: account ?? (this.user?.id === String(userId) ? this.user.account : void 0) };
1245
1648
  writeJson(this.store, KEYS.user(this.options.projectId), this.user);
1246
- this.enqueue("identify", { properties: { traits: (0, import_runtime6.sanitizeProperties)(this.user.traits) } });
1649
+ this.enqueue("identify", { properties: { traits: (0, import_runtime11.sanitizeProperties)(this.user.traits) } });
1247
1650
  await this.syncIdentify();
1248
1651
  this.emitter.emit("identify", { userId: this.user.id, anonymousId: this.anonymousId });
1249
1652
  if (this.isReady) this.evaluate("identify");
@@ -1310,13 +1713,15 @@ var GuidelyClient = class _GuidelyClient {
1310
1713
  this.stopGuide();
1311
1714
  this.user = null;
1312
1715
  this.store.remove(KEYS.user(this.options.projectId));
1313
- this.anonymousId = `anon_${(0, import_runtime6.randomId)(20)}`;
1716
+ this.anonymousId = `anon_${(0, import_runtime11.randomId)(20)}`;
1314
1717
  this.store.set(KEYS.anon, this.anonymousId);
1315
1718
  this.state = emptyState();
1316
1719
  this.persistState();
1317
- this.session = { id: `s_${(0, import_runtime6.randomId)(16)}`, startedAt: nowIso(), shownGuides: [], events: [] };
1720
+ this.session = newSession();
1318
1721
  this.persistSession();
1319
1722
  this.sessionStore.remove(KEYS.active);
1723
+ this.replay?.stop();
1724
+ this.syncCapture();
1320
1725
  this.lastAudiences = /* @__PURE__ */ new Set();
1321
1726
  this.firedDelays.clear();
1322
1727
  this.widgets.forEach((w) => w.destroy());
@@ -1337,6 +1742,8 @@ var GuidelyClient = class _GuidelyClient {
1337
1742
  this.consent = consent;
1338
1743
  if (consent === "denied") this.queue.clear();
1339
1744
  else if (consent === "granted") void this.queue.flush();
1745
+ if (consent !== "granted") this.replay?.stop({ discard: true });
1746
+ this.syncCapture();
1340
1747
  }
1341
1748
  analyticsEnabled() {
1342
1749
  return this.options.analytics && this.consent === "granted" && (this.config?.settings?.analyticsEnabled ?? true);
@@ -1346,8 +1753,9 @@ var GuidelyClient = class _GuidelyClient {
1346
1753
  }
1347
1754
  enqueue(name, extra = {}) {
1348
1755
  if (this.consent === "denied" || !this.options.analytics) return null;
1756
+ if (name !== "page_leave" && name !== "heatmap") this.touchSession();
1349
1757
  const event = {
1350
- eventId: (0, import_runtime6.randomId)(24),
1758
+ eventId: (0, import_runtime11.randomId)(24),
1351
1759
  name,
1352
1760
  occurredAt: nowIso(),
1353
1761
  anonymousId: this.anonymousId,
@@ -1357,8 +1765,9 @@ var GuidelyClient = class _GuidelyClient {
1357
1765
  url: this.collectUrls() ? location.href : void 0,
1358
1766
  ...extra
1359
1767
  };
1360
- if (extra.properties) event.properties = (0, import_runtime6.sanitizeProperties)(extra.properties);
1768
+ if (extra.properties) event.properties = (0, import_runtime11.sanitizeProperties)(extra.properties);
1361
1769
  this.queue.enqueue(event);
1770
+ this.replay?.addCustomEvent("guidely", { name, customName: event.customName, guideId: event.guideId });
1362
1771
  this.emitter.emit("track", event);
1363
1772
  return event;
1364
1773
  }
@@ -1383,23 +1792,32 @@ var GuidelyClient = class _GuidelyClient {
1383
1792
  /** Records a page view and re-evaluates triggers. Called automatically on SPA navigation. */
1384
1793
  page(url = location.href) {
1385
1794
  if (this.destroyed) return;
1386
- this.enqueue("page_view", { properties: { path: safePath(url) } });
1795
+ this.leavePage(url);
1796
+ this.trackPageView(url);
1387
1797
  this.handleNavigation(url, true);
1388
1798
  }
1389
1799
  handleNavigation(url, explicit = false) {
1390
1800
  if (this.destroyed) return;
1391
1801
  const changed = url !== this.lastUrl;
1392
1802
  this.lastUrl = url;
1393
- if (!explicit && changed) this.enqueue("page_view", { properties: { path: safePath(url) } });
1803
+ if (!explicit && changed) {
1804
+ this.leavePage(url);
1805
+ this.trackPageView(url);
1806
+ }
1394
1807
  this.completeChecklistTasksBy("url_visited", url);
1395
1808
  this.active?.controller?.resume();
1396
1809
  if (this.active?.controller) {
1397
1810
  const st = this.active.controller.getState();
1398
1811
  if (st.status === "running" && !this.active.manual) {
1399
1812
  const g = this.active.guide;
1400
- if (g.config.urlPatterns.length && !g.config.urlPatterns.some((p) => (0, import_guide_runtime2.urlMatches)(p, url))) this.active.controller.destroy();
1813
+ if (g.config.urlPatterns.length && !g.config.urlPatterns.some((p) => (0, import_guide_runtime3.urlMatches)(p, url))) this.active.controller.destroy();
1401
1814
  }
1402
1815
  }
1816
+ this.widgets.forEach((w, id) => {
1817
+ if (!w.controller || w.manual) return;
1818
+ const g = this.findGuide(id);
1819
+ if (g?.config.urlPatterns.length && !g.config.urlPatterns.some((p) => (0, import_guide_runtime3.urlMatches)(p, url))) w.controller.destroy();
1820
+ });
1403
1821
  this.firedDelays.clear();
1404
1822
  if (this.isReady) this.evaluate("navigation");
1405
1823
  if (changed) void this.queue.flush();
@@ -1407,10 +1825,255 @@ var GuidelyClient = class _GuidelyClient {
1407
1825
  handleDocumentClick(e) {
1408
1826
  const target = e.target;
1409
1827
  if (!target || typeof target.closest !== "function") return;
1828
+ this.autocaptureClick(e, target);
1410
1829
  this.completeChecklistTasksBy("click", target);
1411
1830
  if (this.isReady) this.evaluate("click", { target });
1412
1831
  }
1413
1832
  // ---------------------------------------------------------------------------
1833
+ // Product analytics: sessions, page views, clicks, errors, session replay
1834
+ // ---------------------------------------------------------------------------
1835
+ /** Records activity; starts a new session after `sessionTimeoutMinutes` of inactivity or 24h. */
1836
+ touchSession() {
1837
+ const now = Date.now();
1838
+ const s = this.session;
1839
+ const timeout = Math.max(1, this.config?.settings?.sessionTimeoutMinutes ?? 30) * 6e4;
1840
+ const last = s.lastActivityAt ?? now;
1841
+ if (now - last > timeout || now - (Date.parse(s.startedAt) || now) > DAY_MS) return this.rotateSession(now);
1842
+ if (now - (s.lastActivityAt ?? 0) < 1e3) return;
1843
+ s.lastActivityAt = now;
1844
+ this.persistSession();
1845
+ }
1846
+ rotateSession(now) {
1847
+ this.session = newSession(now);
1848
+ this.persistSession();
1849
+ this.log("session rotated", this.session.id);
1850
+ if (this.replay?.activeSessionId) this.replay.stop();
1851
+ this.syncReplay();
1852
+ }
1853
+ /** Autocapture, error tracking and replay need analytics on, consent granted and no authoring session. */
1854
+ captureAllowed() {
1855
+ return !this.destroyed && this.analyticsEnabled() && !this.authoring?.isActive();
1856
+ }
1857
+ /** A capability is on when the project enables it (absent on old servers → off) and no option turns it off. */
1858
+ autocaptureOn(key) {
1859
+ const o = this.options.autocapture;
1860
+ return !!this.config?.settings?.autocapture?.[key] && o !== false && !(typeof o === "object" && o[key] === false) && this.captureAllowed();
1861
+ }
1862
+ /** Page views on SPA navigation predate autocapture: only an explicit opt-out stops them. */
1863
+ pageviewsOn() {
1864
+ const o = this.options.autocapture;
1865
+ return this.config?.settings?.autocapture?.pageviews !== false && o !== false && !(typeof o === "object" && o.pageviews === false) && !this.authoring?.isActive();
1866
+ }
1867
+ trackPageView(url) {
1868
+ if (url === this.pageViewUrl || !this.pageviewsOn()) return;
1869
+ this.pageViewUrl = url;
1870
+ this.touchSession();
1871
+ const collect = this.collectUrls();
1872
+ const device = detectDevice(navigator.userAgent, window.innerWidth);
1873
+ const props = { deviceType: device.type, browser: device.browser, os: device.os, screenWidth: window.screen?.width, viewportWidth: window.innerWidth };
1874
+ if (collect) {
1875
+ props.path = pathOf(url);
1876
+ props.title = (document.title || "").slice(0, 200);
1877
+ const ref = !this.session.pageViews ? externalReferrer() : void 0;
1878
+ if (ref) props.referrer = ref;
1879
+ }
1880
+ this.session.pageViews = (this.session.pageViews ?? 0) + 1;
1881
+ this.persistSession();
1882
+ this.enqueue("page_view", { url: collect ? url : void 0, properties: props });
1883
+ }
1884
+ /** Emits `page_leave` for the page being left and starts measuring `nextUrl` (null: page unload). */
1885
+ leavePage(nextUrl) {
1886
+ const t = this.pageTracker;
1887
+ if (!t.url || t.url === nextUrl) return;
1888
+ this.flushHeatmap();
1889
+ if (!this.pageLeft && this.autocaptureOn("pageLeave")) {
1890
+ const info = t.leave();
1891
+ const collect = this.collectUrls();
1892
+ this.enqueue("page_leave", { url: collect ? info.url : void 0, properties: { ...collect ? { path: pathOf(info.url) } : {}, durationMs: info.durationMs, scrollDepth: info.scrollDepth } });
1893
+ }
1894
+ if (nextUrl !== null) {
1895
+ t.start(nextUrl);
1896
+ this.pageLeft = false;
1897
+ }
1898
+ }
1899
+ autocaptureClick(e, target) {
1900
+ const clicks = this.autocaptureOn("clicks");
1901
+ const rage = this.autocaptureOn("rageClicks");
1902
+ const heat = this.autocaptureOn("heatmaps");
1903
+ if (!clicks && !rage && !heat || isIgnoredElement(target)) return;
1904
+ this.touchSession();
1905
+ const el = interactiveElement(target);
1906
+ const opts = { mask: this.maskSelector(), maskAll: this.maskAllText(), collectUrls: this.collectUrls() };
1907
+ if (clicks && el) this.enqueue("click", { properties: { ...describeClick(el, e, opts) } });
1908
+ const count = this.rage.add(e.pageX ?? 0, e.pageY ?? 0, Date.now());
1909
+ if (rage && count) this.enqueue("rage_click", { properties: { ...describeClick(el ?? target, e, opts), count } });
1910
+ if (!heat) return;
1911
+ const url = location.href;
1912
+ if (this.heatmap.url !== url) this.flushHeatmap();
1913
+ if (this.heatmap.add(url, heatmapPoint(el ?? target, e, this.rage.burst > 0), this.rage.burst)) this.flushHeatmap();
1914
+ }
1915
+ /** Bins one pointer sample into the heatmap buffer. Not session activity. */
1916
+ sampleMove(e) {
1917
+ const target = e.target;
1918
+ if (document.visibilityState === "hidden" || !target?.closest || !this.autocaptureOn("mouseMovement") || isIgnoredElement(target)) return;
1919
+ const url = location.href;
1920
+ if (this.heatmap.url !== url) this.flushHeatmap();
1921
+ if (this.heatmap.move(url, movementBin(interactiveElement(target) ?? target, e))) this.flushHeatmap();
1922
+ }
1923
+ /** Sends the buffered heatmap clicks as one `heatmap` event attributed to the page they happened on. */
1924
+ flushHeatmap() {
1925
+ const batch = this.heatmap.take();
1926
+ if (!batch || !this.captureAllowed()) return;
1927
+ const collect = this.collectUrls();
1928
+ const de = document.documentElement;
1929
+ 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 };
1930
+ if (!collect) delete properties.path;
1931
+ this.enqueue("heatmap", { url: collect ? batch.url : void 0, properties });
1932
+ }
1933
+ captureGlobalException(props) {
1934
+ if (!props || isGuidelyException(props) || this.options.captureErrors === false) return;
1935
+ if (!this.config) {
1936
+ if (this.earlyExceptions.length < 10) this.earlyExceptions.push([props, void 0]);
1937
+ return;
1938
+ }
1939
+ this.sendException(props);
1940
+ }
1941
+ /**
1942
+ * Reports a caught error as an `exception` event (`source: "manual"`, `handled: true`), grouped
1943
+ * into an issue on the Errors page. Extra properties are attached to the occurrence. Needs the
1944
+ * project's error tracking on (plus analytics + consent); `captureErrors: false` does not stop it.
1945
+ */
1946
+ captureException(error, properties) {
1947
+ if (this.destroyed) return;
1948
+ const props = exceptionFromError(error, "manual", true);
1949
+ if (!this.config) {
1950
+ if (this.earlyExceptions.length < 10) this.earlyExceptions.push([props, properties]);
1951
+ return;
1952
+ }
1953
+ this.sendException(props, properties);
1954
+ }
1955
+ sendException(props, extra) {
1956
+ if (!this.config?.settings?.errorTracking?.enabled || !this.captureAllowed() || !this.exceptionThrottle.allow(props)) return;
1957
+ const count = this.session.exceptions ?? 0;
1958
+ if (count >= MAX_SESSION_EXCEPTIONS) return;
1959
+ const properties = { ...props };
1960
+ if (this.options.release) properties.release = String(this.options.release).slice(0, 100);
1961
+ for (const [k, v] of Object.entries(extra ?? {})) if (!(k in properties)) properties[k] = v;
1962
+ this.enqueue("exception", { properties });
1963
+ this.session.exceptions = count + 1;
1964
+ this.persistSession();
1965
+ }
1966
+ maskSelector() {
1967
+ return validSelectors([...DEFAULT_MASK_SELECTORS, ...this.config?.settings?.maskSelectors || [], ...this.options.maskSelectors || []]).join(",");
1968
+ }
1969
+ maskAllText() {
1970
+ const o = this.options.sessionReplay;
1971
+ return !!this.config?.settings?.replay?.maskAllText || typeof o === "object" && !!o.maskAllText;
1972
+ }
1973
+ /** Replay settings for this installation, or null when the project (or an option) disables replay. */
1974
+ replayConfig() {
1975
+ const r = this.config?.settings?.replay;
1976
+ const o = this.options.sessionReplay;
1977
+ if (!r?.enabled || o === false) return null;
1978
+ const opt = typeof o === "object" ? o : {};
1979
+ const extra = opt.blockSelectors || [];
1980
+ return {
1981
+ sampleRate: r.sampleRate ?? 100,
1982
+ config: {
1983
+ recordConsole: r.recordConsole !== false,
1984
+ maskTextSelector: this.maskAllText() ? "*" : this.maskSelector(),
1985
+ blockSelector: validSelectors(["[data-guidely-block]", ...r.blockSelectors || [], ...extra]).join(","),
1986
+ network: r.recordNetwork && opt.recordNetwork !== false ? { payloads: !!r.recordNetworkPayloads && opt.recordNetworkPayloads !== false, apiUrl: this.options.apiUrl, mask: opt.maskNetworkRequest } : null
1987
+ }
1988
+ };
1989
+ }
1990
+ /** Reconciles capture with the current config / consent / authoring state. Idempotent. */
1991
+ syncCapture() {
1992
+ if (!this.isReady || this.destroyed) return;
1993
+ if (this.config && !this.initialPageViewSent && this.config.settings?.autocapture) {
1994
+ this.initialPageViewSent = true;
1995
+ this.trackPageView(location.href);
1996
+ }
1997
+ if (this.config) {
1998
+ for (const [props, extra] of this.earlyExceptions.splice(0)) {
1999
+ if (props.handled) this.sendException(props, extra);
2000
+ else this.captureGlobalException(props);
2001
+ }
2002
+ }
2003
+ this.syncReplay();
2004
+ }
2005
+ syncReplay() {
2006
+ const cfg = this.isReady && this.captureAllowed() ? this.replayConfig() : null;
2007
+ if (cfg && this.session.replay === void 0) {
2008
+ this.session.replay = (0, import_runtime11.rolloutBucket)(this.session.id, "replay") < cfg.sampleRate;
2009
+ this.persistSession();
2010
+ }
2011
+ if (!cfg || !this.session.replay) {
2012
+ if (this.replay?.activeSessionId) this.replay.stop({ discard: !this.analyticsEnabled() });
2013
+ return;
2014
+ }
2015
+ if (this.replay) return this.replay.start(this.session.id, cfg.config);
2016
+ if (this.replayLoading) return;
2017
+ this.replayLoading = true;
2018
+ loadRecorder().then(
2019
+ (api) => {
2020
+ this.replayLoading = false;
2021
+ if (this.destroyed || this.replay) return;
2022
+ this.replay = api.createReplay({
2023
+ url: (params) => this.api.url("/replay", params),
2024
+ fetch: () => this.options.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : null),
2025
+ meta: () => {
2026
+ const device = detectDevice(navigator.userAgent, window.innerWidth);
2027
+ return {
2028
+ anonymousId: this.anonymousId,
2029
+ userId: this.user?.id || null,
2030
+ url: this.collectUrls() ? location.href : void 0,
2031
+ device: { type: device.type, browser: device.browser, os: device.os },
2032
+ viewport: { width: window.innerWidth, height: window.innerHeight },
2033
+ sdk: { name: SDK_NAME, version: SDK_VERSION }
2034
+ };
2035
+ },
2036
+ onDisabled: (sessionId) => {
2037
+ if (sessionId !== this.session.id) return;
2038
+ this.session.replay = false;
2039
+ this.persistSession();
2040
+ this.replay?.stop({ discard: true });
2041
+ },
2042
+ onError: (err) => this.reportError(err)
2043
+ });
2044
+ this.syncReplay();
2045
+ },
2046
+ (err) => {
2047
+ this.replayLoading = false;
2048
+ this.reportError(err);
2049
+ }
2050
+ );
2051
+ }
2052
+ /**
2053
+ * Records this session even when it was not sampled, provided the project has session replay
2054
+ * enabled (and analytics + consent allow it). The decision sticks for the rest of the session.
2055
+ */
2056
+ startSessionRecording() {
2057
+ if (this.destroyed) return;
2058
+ this.session.replay = true;
2059
+ this.persistSession();
2060
+ this.syncReplay();
2061
+ }
2062
+ /** Stops recording for the rest of this session (what was recorded so far is uploaded). */
2063
+ stopSessionRecording() {
2064
+ this.session.replay = false;
2065
+ this.persistSession();
2066
+ this.replay?.stop();
2067
+ }
2068
+ /** True while the session is being recorded. */
2069
+ isRecording() {
2070
+ return !!this.replay?.isRecording();
2071
+ }
2072
+ /** The current analytics session id (rotates after inactivity; also the replay id). */
2073
+ getSessionId() {
2074
+ return this.session.id;
2075
+ }
2076
+ // ---------------------------------------------------------------------------
1414
2077
  // Targeting + triggers
1415
2078
  // ---------------------------------------------------------------------------
1416
2079
  /** Builds the targeting context for the current moment. */
@@ -1431,17 +2094,18 @@ var GuidelyClient = class _GuidelyClient {
1431
2094
  seenGuides: keysOf(Object.keys(guides).filter((id) => (guides[id]?.impressions ?? 0) > 0)),
1432
2095
  completedChecklists: keysOf(completedChecklists),
1433
2096
  audienceIds: this.state.audienceIds,
1434
- environment: this.options.environment
2097
+ environment: this.options.environment,
2098
+ platform: "web"
1435
2099
  });
1436
2100
  }
1437
2101
  isTargeted(guide, ctx, url) {
1438
2102
  const c = guide.config;
1439
- if (c.urlPatterns.length && !c.urlPatterns.some((p) => (0, import_guide_runtime2.urlMatches)(p, url))) return false;
1440
- if (c.trigger.urlPattern && !(0, import_guide_runtime2.urlMatches)(c.trigger.urlPattern, url)) return false;
2103
+ if (c.urlPatterns.length && !c.urlPatterns.some((p) => (0, import_guide_runtime3.urlMatches)(p, url))) return false;
2104
+ if (c.trigger.urlPattern && !(0, import_guide_runtime3.urlMatches)(c.trigger.urlPattern, url)) return false;
1441
2105
  if (!isWithinSchedule(guide)) return false;
1442
2106
  if (guide.kind === "survey" && (this.state.surveysSubmitted.includes(guide.id) || this.state.surveysSubmitted.includes(guide.key))) return false;
1443
2107
  ctx.rolloutSalt = guide.id;
1444
- return (0, import_runtime6.evaluateRules)(c.targeting, ctx);
2108
+ return (0, import_runtime11.evaluateRules)(c.targeting, ctx);
1445
2109
  }
1446
2110
  isEligible(guide, ctx, url) {
1447
2111
  if (!this.isTargeted(guide, ctx, url)) return false;
@@ -1456,9 +2120,10 @@ var GuidelyClient = class _GuidelyClient {
1456
2120
  const t = guide.config.trigger;
1457
2121
  switch (t.type) {
1458
2122
  case "page_load":
2123
+ if (t.delayMs > 0) return reason === "timer" && detail.guideId === guide.id;
1459
2124
  return reason === "ready" || reason === "navigation" || reason === "identify";
1460
2125
  case "url_match":
1461
- return (reason === "ready" || reason === "navigation" || reason === "identify") && (0, import_guide_runtime2.urlMatches)(t.value, location.href);
2126
+ return (reason === "ready" || reason === "navigation" || reason === "identify") && (0, import_guide_runtime3.urlMatches)(t.value, location.href);
1462
2127
  case "event":
1463
2128
  return reason === "event" && !!t.value && detail.eventName === t.value;
1464
2129
  case "element_click":
@@ -1534,7 +2199,7 @@ var GuidelyClient = class _GuidelyClient {
1534
2199
  this.visibilityObservers.delete(guide.id);
1535
2200
  };
1536
2201
  this.visibilityObservers.set(guide.id, stop);
1537
- (0, import_guide_runtime2.waitForTarget)([{ kind: "css", value: selector }], { timeoutMs: 0, signal: ac.signal }).then((el) => {
2202
+ (0, import_guide_runtime3.waitForTarget)([{ kind: "css", value: selector }], { timeoutMs: 0, signal: ac.signal }).then((el) => {
1538
2203
  if (ac.signal.aborted) return;
1539
2204
  const fire = () => {
1540
2205
  stop();
@@ -1630,7 +2295,7 @@ var GuidelyClient = class _GuidelyClient {
1630
2295
  if (this.authoring?.isActive()) return;
1631
2296
  const exclusive = isExclusiveGuide(guide);
1632
2297
  const entry = { guide, manual, startUrl: location.href };
1633
- const controller = (0, import_guide_runtime2.runGuide)(guide, {
2298
+ const controller = (0, import_guide_runtime3.runGuide)(guide, {
1634
2299
  theme: this.theme(),
1635
2300
  ctx: () => this.getContext(),
1636
2301
  startAtStep: stepId,
@@ -1676,7 +2341,7 @@ var GuidelyClient = class _GuidelyClient {
1676
2341
  });
1677
2342
  entry.controller = controller;
1678
2343
  if (exclusive) this.active = entry;
1679
- else this.widgets.set(guide.id, { destroy: () => controller.destroy(), controller });
2344
+ else this.widgets.set(guide.id, { destroy: () => controller.destroy(), controller, manual });
1680
2345
  controller.start();
1681
2346
  if (controller.getState().status === "ended") {
1682
2347
  if (exclusive && this.active === entry) this.active = null;
@@ -1687,10 +2352,10 @@ var GuidelyClient = class _GuidelyClient {
1687
2352
  if (this.authoring?.isActive()) return;
1688
2353
  const config = guide.config.checklist;
1689
2354
  if (!config || this.widgets.has(guide.id)) return;
1690
- const scope = (0, import_guide_runtime3.createScope)(this.theme(), guide.config.appearance);
2355
+ const scope = (0, import_guide_runtime4.createScope)(this.theme(), guide.config.appearance);
1691
2356
  const progress = this.getChecklistProgress(guide.id);
1692
2357
  const first = !this.state.guides[guide.id]?.impressions;
1693
- const widget = (0, import_guide_runtime2.renderChecklist)({
2358
+ const widget = (0, import_guide_runtime3.renderChecklist)({
1694
2359
  scope: scope.el,
1695
2360
  config,
1696
2361
  progress,
@@ -1709,7 +2374,7 @@ var GuidelyClient = class _GuidelyClient {
1709
2374
  if (this.authoring?.isActive()) return;
1710
2375
  const config = guide.config.survey;
1711
2376
  if (!config) return;
1712
- const scope = (0, import_guide_runtime3.createScope)(this.theme(), guide.config.appearance);
2377
+ const scope = (0, import_guide_runtime4.createScope)(this.theme(), guide.config.appearance);
1713
2378
  const entry = { guide, manual, startUrl: location.href, scope };
1714
2379
  const finish = (reason) => {
1715
2380
  widget.destroy();
@@ -1722,13 +2387,13 @@ var GuidelyClient = class _GuidelyClient {
1722
2387
  }
1723
2388
  this.setTimer(() => this.evaluate("after_guide", { endedGuide: guide }), 0);
1724
2389
  };
1725
- const widget = (0, import_guide_runtime2.renderSurvey)({
2390
+ const widget = (0, import_guide_runtime3.renderSurvey)({
1726
2391
  scope: scope.el,
1727
2392
  config,
1728
2393
  title: guide.name,
1729
2394
  onDismiss: () => finish(this.state.surveysSubmitted.includes(guide.id) ? "submitted" : "dismissed"),
1730
2395
  onSubmit: (answers) => {
1731
- const responseId = (0, import_runtime6.randomId)(24);
2396
+ const responseId = (0, import_runtime11.randomId)(24);
1732
2397
  this.state.surveysSubmitted.push(guide.id);
1733
2398
  this.persistState();
1734
2399
  this.recordEnd(guide, "completed");
@@ -1770,9 +2435,9 @@ var GuidelyClient = class _GuidelyClient {
1770
2435
  }
1771
2436
  this.destroyMessenger();
1772
2437
  if (rc && rc.enabled && !this.resourceCenter) {
1773
- const scope = (0, import_guide_runtime3.createScope)(this.theme());
2438
+ const scope = (0, import_guide_runtime4.createScope)(this.theme());
1774
2439
  this.resourceCenterScope = scope;
1775
- this.resourceCenter = (0, import_guide_runtime2.renderResourceCenter)({
2440
+ this.resourceCenter = (0, import_guide_runtime3.renderResourceCenter)({
1776
2441
  scope: scope.el,
1777
2442
  config: rc,
1778
2443
  guides: this.config.guides.map((g) => ({ id: g.id, key: g.key, name: g.name, kind: g.kind })),
@@ -1807,7 +2472,7 @@ var GuidelyClient = class _GuidelyClient {
1807
2472
  this.messengerLoading = import("@guidelytech/guide-runtime/messenger").then(({ renderMessenger }) => {
1808
2473
  const cfg = this.config?.messenger;
1809
2474
  if (this.destroyed || !this.config || !cfg?.enabled || this.messenger) return this.messenger;
1810
- const scope = (0, import_guide_runtime3.createScope)(this.theme());
2475
+ const scope = (0, import_guide_runtime4.createScope)(this.theme());
1811
2476
  this.messengerScope = scope;
1812
2477
  this.messenger = renderMessenger({
1813
2478
  scope: scope.el,
@@ -1886,7 +2551,7 @@ var GuidelyClient = class _GuidelyClient {
1886
2551
  if (progress.completedTaskIds.includes(task.id)) return;
1887
2552
  progress.completedTaskIds.push(task.id);
1888
2553
  this.enqueue("checklist_task_completed", { guideId: guide.id, guideVersion: guide.version, properties: { taskId: task.id } });
1889
- const complete = (0, import_guide_runtime2.isChecklistComplete)(config, progress) && !progress.completedAt;
2554
+ const complete = (0, import_guide_runtime3.isChecklistComplete)(config, progress) && !progress.completedAt;
1890
2555
  if (complete) {
1891
2556
  progress.completedAt = nowIso();
1892
2557
  this.enqueue("checklist_completed", { guideId: guide.id, guideVersion: guide.version });
@@ -1909,7 +2574,7 @@ var GuidelyClient = class _GuidelyClient {
1909
2574
  if (task.completion !== kind || !task.completionValue) continue;
1910
2575
  let hit = false;
1911
2576
  if (kind === "event") hit = value === task.completionValue;
1912
- else if (kind === "url_visited") hit = (0, import_guide_runtime2.urlMatches)(task.completionValue, value);
2577
+ else if (kind === "url_visited") hit = (0, import_guide_runtime3.urlMatches)(task.completionValue, value);
1913
2578
  else if (kind === "guide_completed") hit = value.id === task.completionValue || value.key === task.completionValue;
1914
2579
  else if (kind === "click") {
1915
2580
  try {
@@ -2070,6 +2735,8 @@ var GuidelyClient = class _GuidelyClient {
2070
2735
  onError: (err) => this.reportError(err),
2071
2736
  onActive: (active) => {
2072
2737
  this.emitter.emit("authoring", { active });
2738
+ if (active) this.replay?.stop({ discard: true });
2739
+ else this.syncCapture();
2073
2740
  if (active) {
2074
2741
  this.stopGuide();
2075
2742
  this.widgets.forEach((w) => w.destroy());
@@ -2120,12 +2787,23 @@ var GuidelyClient = class _GuidelyClient {
2120
2787
  }
2121
2788
  };
2122
2789
  var current = null;
2123
- function safePath(url) {
2790
+ function webOnly(config) {
2791
+ return config.guides.every((g) => (0, import_runtime11.supportsPlatform)(g.config, "web")) ? config : { ...config, guides: config.guides.filter((g) => (0, import_runtime11.supportsPlatform)(g.config, "web")) };
2792
+ }
2793
+ function pathOf(url) {
2794
+ try {
2795
+ return new URL(url, "http://localhost").pathname;
2796
+ } catch {
2797
+ return url.replace(/[?#].*$/, "");
2798
+ }
2799
+ }
2800
+ function externalReferrer() {
2124
2801
  try {
2125
- const u = new URL(url, "http://localhost");
2126
- return u.pathname + u.search;
2802
+ if (!document.referrer) return void 0;
2803
+ const u = new URL(document.referrer);
2804
+ return u.origin === location.origin ? void 0 : u.origin + u.pathname;
2127
2805
  } catch {
2128
- return url;
2806
+ return void 0;
2129
2807
  }
2130
2808
  }
2131
2809
  function createGuidely(options) {