@guidelytech/react-native 0.12.0 → 0.13.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.cjs CHANGED
@@ -37,8 +37,11 @@ __export(index_exports, {
37
37
  InMemoryStickyBucketStore: () => import_flags6.InMemoryStickyBucketStore,
38
38
  Messenger: () => Messenger,
39
39
  RageClickDetector: () => RageClickDetector,
40
+ ReplayUploader: () => ReplayUploader,
41
+ RrwebEvents: () => RrwebEvents,
40
42
  SDK_NAME: () => SDK_NAME,
41
43
  SDK_VERSION: () => SDK_VERSION,
44
+ SessionRecorder: () => SessionRecorder,
42
45
  TapTracker: () => TapTracker,
43
46
  TargetRegistry: () => TargetRegistry,
44
47
  createFlagsClient: () => import_flags6.createFlagsClient,
@@ -2492,6 +2495,425 @@ var Messenger = class {
2492
2495
  }
2493
2496
  };
2494
2497
 
2498
+ // src/core/replay.ts
2499
+ var RrwebEvents = {
2500
+ IMG_ID: 8,
2501
+ TOUCH_START: 7,
2502
+ TOUCH_END: 9,
2503
+ CLICK: 2,
2504
+ CSS: "html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}img{display:block;width:100%;height:100%;object-fit:contain}",
2505
+ dataUrl(base64) {
2506
+ return `data:image/jpeg;base64,${base64}`;
2507
+ },
2508
+ meta(timestamp, href, width, height) {
2509
+ return JSON.stringify({ type: 4, data: { href, width, height }, timestamp });
2510
+ },
2511
+ fullSnapshot(timestamp, dataUrl, width, height) {
2512
+ const style = { type: 2, id: 5, tagName: "style", attributes: {}, childNodes: [{ type: 3, id: 6, textContent: RrwebEvents.CSS, isStyle: true }] };
2513
+ const img = { type: 2, id: 8, tagName: "img", attributes: { src: dataUrl, width, height, alt: "" }, childNodes: [] };
2514
+ const html = { type: 2, id: 3, tagName: "html", attributes: {}, childNodes: [{ type: 2, id: 4, tagName: "head", attributes: {}, childNodes: [style] }, { type: 2, id: 7, tagName: "body", attributes: {}, childNodes: [img] }] };
2515
+ const node = { type: 0, id: 1, childNodes: [{ type: 1, id: 2, name: "html", publicId: "", systemId: "" }, html] };
2516
+ return JSON.stringify({ type: 2, data: { node, initialOffset: { left: 0, top: 0 } }, timestamp });
2517
+ },
2518
+ frame(timestamp, dataUrl) {
2519
+ return JSON.stringify({ type: 3, data: { source: 0, texts: [], attributes: [{ id: 8, attributes: { src: dataUrl } }], removes: [], adds: [] }, timestamp });
2520
+ },
2521
+ /** MouseInteraction (source 2): 7 TouchStart, 9 TouchEnd, 2 Click. */
2522
+ interaction(timestamp, type, x, y) {
2523
+ return JSON.stringify({ type: 3, data: { source: 2, type, id: 8, x: round1(x), y: round1(y) }, timestamp });
2524
+ },
2525
+ touchMove(timestamp, x, y) {
2526
+ return JSON.stringify({ type: 3, data: { source: 6, positions: [{ x: round1(x), y: round1(y), id: 8, timeOffset: 0 }] }, timestamp });
2527
+ },
2528
+ custom(timestamp, tag, payload) {
2529
+ return JSON.stringify({ type: 5, data: { tag, payload }, timestamp });
2530
+ }
2531
+ };
2532
+ var round1 = (v) => Math.round(v * 10) / 10;
2533
+ var UPLOAD_TIMEOUT_MS = 3e4;
2534
+ var ReplayUploader = class {
2535
+ constructor(opts) {
2536
+ __publicField(this, "opts", opts);
2537
+ __publicField(this, "windowId", randomId(16));
2538
+ __publicField(this, "buffer", null);
2539
+ __publicField(this, "queue", []);
2540
+ __publicField(this, "queuedBytes", 0);
2541
+ __publicField(this, "seqs", /* @__PURE__ */ new Map());
2542
+ __publicField(this, "inflight", null);
2543
+ __publicField(this, "flushTimer", null);
2544
+ __publicField(this, "retryTimer", null);
2545
+ __publicField(this, "attempt", 0);
2546
+ __publicField(this, "destroyed", false);
2547
+ __publicField(this, "offRecover");
2548
+ this.offRecover = opts.health?.onRecover?.(() => {
2549
+ this.clearRetry();
2550
+ this.attempt = 0;
2551
+ this.flush();
2552
+ }) ?? null;
2553
+ }
2554
+ get pendingChunks() {
2555
+ return this.queue.length;
2556
+ }
2557
+ get bufferedEvents() {
2558
+ return this.buffer?.events.length ?? 0;
2559
+ }
2560
+ push(sessionId, json) {
2561
+ if (this.destroyed) return;
2562
+ if (this.buffer && this.buffer.sessionId !== sessionId) this.seal();
2563
+ const chunk = this.buffer ?? (this.buffer = { sessionId, meta: this.opts.meta(), events: [], bytes: 0 });
2564
+ chunk.events.push(json);
2565
+ chunk.bytes += json.length;
2566
+ if (chunk.bytes > (this.opts.maxBufferBytes ?? 1e6)) this.flush();
2567
+ else if (!this.flushTimer) {
2568
+ this.flushTimer = setTimeout(() => {
2569
+ this.flushTimer = null;
2570
+ this.flush();
2571
+ }, this.opts.flushIntervalMs ?? 1e4);
2572
+ }
2573
+ }
2574
+ /** Seals the buffer and uploads the backlog (one chunk at a time). */
2575
+ flush() {
2576
+ if (this.destroyed) return;
2577
+ this.seal();
2578
+ if (this.retryTimer || this.inflight || !this.queue.length) return;
2579
+ void this.sendNext();
2580
+ }
2581
+ /** Drops everything buffered or queued (consent withdrawn, server disabled replay). */
2582
+ discard() {
2583
+ this.buffer = null;
2584
+ this.queue = [];
2585
+ this.queuedBytes = 0;
2586
+ this.clearFlushTimer();
2587
+ this.clearRetry();
2588
+ this.attempt = 0;
2589
+ }
2590
+ /** Uploads what is left once; no retries afterwards. */
2591
+ destroy() {
2592
+ this.flush();
2593
+ this.destroyed = true;
2594
+ this.offRecover?.();
2595
+ this.clearFlushTimer();
2596
+ this.clearRetry();
2597
+ }
2598
+ clearFlushTimer() {
2599
+ if (this.flushTimer) clearTimeout(this.flushTimer);
2600
+ this.flushTimer = null;
2601
+ }
2602
+ clearRetry() {
2603
+ if (this.retryTimer) clearTimeout(this.retryTimer);
2604
+ this.retryTimer = null;
2605
+ }
2606
+ scheduleRetry(ms) {
2607
+ this.retryTimer = setTimeout(() => {
2608
+ this.retryTimer = null;
2609
+ this.flush();
2610
+ }, ms);
2611
+ }
2612
+ seal() {
2613
+ this.clearFlushTimer();
2614
+ const chunk = this.buffer;
2615
+ this.buffer = null;
2616
+ if (!chunk || !chunk.events.length) return;
2617
+ this.queue.push(chunk);
2618
+ this.queuedBytes += chunk.bytes;
2619
+ const max = this.opts.maxQueuedBytes ?? 5e6;
2620
+ while (this.queuedBytes > max && this.queue.length > 1) {
2621
+ const i = this.inflight && this.queue[0] === this.inflight ? 1 : 0;
2622
+ const [dropped] = this.queue.splice(i, 1);
2623
+ if (!dropped) break;
2624
+ this.queuedBytes -= dropped.bytes;
2625
+ this.opts.log?.(`replay backlog full: dropped a chunk of ${dropped.events.length} events`);
2626
+ }
2627
+ }
2628
+ nextSeq(chunk) {
2629
+ if (chunk.seq === void 0) {
2630
+ const seq = this.seqs.get(chunk.sessionId) ?? 0;
2631
+ this.seqs.set(chunk.sessionId, seq + 1);
2632
+ chunk.seq = seq;
2633
+ }
2634
+ return chunk.seq;
2635
+ }
2636
+ body(chunk) {
2637
+ const head = JSON.stringify({ sessionId: chunk.sessionId, windowId: this.windowId, seq: this.nextSeq(chunk), ...chunk.meta });
2638
+ return `${head.slice(0, -1)},"events":[${chunk.events.join(",")}]}`;
2639
+ }
2640
+ async sendNext() {
2641
+ const chunk = this.queue[0];
2642
+ if (!chunk) return;
2643
+ const wait = this.destroyed ? 0 : this.opts.health?.waitMs() ?? 0;
2644
+ if (wait > 0) return this.scheduleRetry(wait);
2645
+ this.inflight = chunk;
2646
+ const result = await this.upload(this.body(chunk));
2647
+ this.onResult(chunk, result);
2648
+ }
2649
+ async upload(json) {
2650
+ const f = this.opts.fetch();
2651
+ if (!f) return "drop";
2652
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
2653
+ const timer = ac ? setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS) : null;
2654
+ try {
2655
+ let res;
2656
+ try {
2657
+ res = await f(this.opts.url(), { method: "POST", body: json, headers: { "Content-Type": "application/json" }, signal: ac?.signal });
2658
+ } catch {
2659
+ this.opts.health?.failure();
2660
+ return "retry";
2661
+ }
2662
+ if (res.status === 429 || res.status >= 500) this.opts.health?.failure();
2663
+ else this.opts.health?.success();
2664
+ if (res.ok) {
2665
+ const data = await res.json().catch(() => null);
2666
+ return data?.disabled ? "disabled" : "ok";
2667
+ }
2668
+ return res.status >= 400 && res.status < 500 && res.status !== 429 ? "drop" : "retry";
2669
+ } finally {
2670
+ if (timer) clearTimeout(timer);
2671
+ }
2672
+ }
2673
+ onResult(chunk, result) {
2674
+ if (this.inflight === chunk) this.inflight = null;
2675
+ if (result === "retry" && this.queue.includes(chunk)) {
2676
+ if (!this.destroyed) {
2677
+ const max = this.opts.maxAttempts ?? 5;
2678
+ this.attempt = Math.min(this.attempt + 1, max);
2679
+ const delay = this.attempt >= max ? this.opts.slowRetryMs ?? 6e4 : backoffMs(this.attempt - 1, 1e3, 8e3);
2680
+ return this.scheduleRetry(Math.max(delay, this.opts.health?.waitMs() ?? 0));
2681
+ }
2682
+ this.opts.log?.("replay upload failed after the recorder stopped; dropping the chunk");
2683
+ }
2684
+ this.attempt = 0;
2685
+ this.remove(chunk);
2686
+ if (result === "disabled") {
2687
+ for (const c of this.queue.slice()) if (c.sessionId === chunk.sessionId) this.remove(c);
2688
+ if (this.buffer?.sessionId === chunk.sessionId) this.buffer = null;
2689
+ this.opts.onDisabled(chunk.sessionId);
2690
+ }
2691
+ if (this.queue.length && !this.retryTimer && !this.inflight) void this.sendNext();
2692
+ }
2693
+ remove(chunk) {
2694
+ const i = this.queue.indexOf(chunk);
2695
+ if (i >= 0) {
2696
+ this.queue.splice(i, 1);
2697
+ this.queuedBytes -= chunk.bytes;
2698
+ }
2699
+ }
2700
+ };
2701
+ var TOUCH_SETTLE_MS = 200;
2702
+ var IDLE_FRAMES = 3;
2703
+ var MAX_IDLE_INTERVAL_MS = 5e3;
2704
+ var SessionRecorder = class {
2705
+ constructor(opts) {
2706
+ __publicField(this, "opts", opts);
2707
+ __publicField(this, "intervalMs");
2708
+ __publicField(this, "now");
2709
+ __publicField(this, "sid", null);
2710
+ __publicField(this, "foreground", true);
2711
+ __publicField(this, "generation", 0);
2712
+ __publicField(this, "timer", null);
2713
+ __publicField(this, "timerDue", 0);
2714
+ __publicField(this, "capturing", false);
2715
+ __publicField(this, "needKeyframe", true);
2716
+ __publicField(this, "hasKeyframe", false);
2717
+ __publicField(this, "lastFrame", null);
2718
+ __publicField(this, "unchanged", 0);
2719
+ __publicField(this, "captureSoon", false);
2720
+ __publicField(this, "pendingCustom", []);
2721
+ __publicField(this, "touchDown", false);
2722
+ __publicField(this, "downX", 0);
2723
+ __publicField(this, "downY", 0);
2724
+ __publicField(this, "downAt", 0);
2725
+ __publicField(this, "maxDistance", 0);
2726
+ __publicField(this, "lastMoveAt", 0);
2727
+ __publicField(this, "originX", 0);
2728
+ __publicField(this, "originY", 0);
2729
+ /** Viewport of the last frame (points), for chunk metadata. */
2730
+ __publicField(this, "viewportWidth", 0);
2731
+ __publicField(this, "viewportHeight", 0);
2732
+ this.intervalMs = Math.max(500, opts.intervalMs);
2733
+ this.now = opts.now ?? Date.now;
2734
+ }
2735
+ get sessionId() {
2736
+ return this.sid;
2737
+ }
2738
+ get isRecording() {
2739
+ return this.sid !== null;
2740
+ }
2741
+ get currentIntervalMs() {
2742
+ return this.unchanged >= IDLE_FRAMES ? Math.max(this.intervalMs, Math.min(4 * this.intervalMs, MAX_IDLE_INTERVAL_MS)) : this.intervalMs;
2743
+ }
2744
+ /** Starts (or keeps) recording `sessionId`; another session stops the current one first (uploading it). */
2745
+ start(sessionId) {
2746
+ if (this.sid === sessionId) return;
2747
+ if (this.sid !== null) this.stop(false);
2748
+ this.sid = sessionId;
2749
+ this.generation++;
2750
+ this.needKeyframe = true;
2751
+ this.hasKeyframe = false;
2752
+ this.lastFrame = null;
2753
+ this.unchanged = 0;
2754
+ this.captureSoon = false;
2755
+ this.pendingCustom = [];
2756
+ this.touchDown = false;
2757
+ if (this.foreground) this.schedule(0);
2758
+ }
2759
+ /** Stops recording; buffered events are uploaded unless `discard`. */
2760
+ stop(discard) {
2761
+ if (this.sid === null) return;
2762
+ this.sid = null;
2763
+ this.generation++;
2764
+ this.clearTimer();
2765
+ this.capturing = false;
2766
+ this.pendingCustom = [];
2767
+ this.touchDown = false;
2768
+ if (discard) this.opts.uploader.discard();
2769
+ else this.opts.uploader.flush();
2770
+ }
2771
+ /** App backgrounded: no captures; uploads what is buffered. */
2772
+ pause() {
2773
+ this.foreground = false;
2774
+ this.clearTimer();
2775
+ this.generation++;
2776
+ this.capturing = false;
2777
+ this.touchDown = false;
2778
+ this.opts.uploader.flush();
2779
+ }
2780
+ /** App foregrounded: resume with a keyframe. */
2781
+ resume() {
2782
+ if (this.foreground) return;
2783
+ this.foreground = true;
2784
+ this.needKeyframe = true;
2785
+ this.unchanged = 0;
2786
+ if (this.sid !== null) this.schedule(0);
2787
+ }
2788
+ /** A new screen: the next frame is a keyframe with the new href. */
2789
+ onScreen() {
2790
+ if (this.sid === null) return;
2791
+ this.needKeyframe = true;
2792
+ this.activity();
2793
+ }
2794
+ activity() {
2795
+ const wasIdle = this.unchanged >= IDLE_FRAMES;
2796
+ this.unchanged = 0;
2797
+ if (wasIdle && this.sid !== null && this.foreground && !this.capturing && !this.touchDown) this.schedule(this.intervalMs, true);
2798
+ }
2799
+ /** Adds the `guidely` marker for an analytics event. */
2800
+ addGuidelyEvent(name, customName, guideId) {
2801
+ const id = this.sid;
2802
+ if (id === null) return;
2803
+ const payload = { name };
2804
+ if (customName) payload.customName = customName;
2805
+ if (guideId) payload.guideId = guideId;
2806
+ this.activity();
2807
+ const now = this.now();
2808
+ if (!this.hasKeyframe) {
2809
+ if (this.pendingCustom.length < 50) this.pendingCustom.push([now, payload]);
2810
+ return;
2811
+ }
2812
+ this.opts.uploader.push(id, RrwebEvents.custom(now, "guidely", payload));
2813
+ }
2814
+ /** A primary-pointer touch at (x, y) points from the React root view. */
2815
+ touch(phase, rootX, rootY) {
2816
+ const id = this.sid;
2817
+ if (id === null || !this.foreground) return;
2818
+ const x = rootX + this.originX;
2819
+ const y = rootY + this.originY;
2820
+ const now = this.now();
2821
+ const emit = this.hasKeyframe;
2822
+ const push = (json) => emit && this.opts.uploader.push(id, json);
2823
+ switch (phase) {
2824
+ case "down":
2825
+ this.touchDown = true;
2826
+ this.unchanged = 0;
2827
+ this.downX = x;
2828
+ this.downY = y;
2829
+ this.downAt = now;
2830
+ this.maxDistance = 0;
2831
+ this.lastMoveAt = now;
2832
+ push(RrwebEvents.interaction(now, RrwebEvents.TOUCH_START, x, y));
2833
+ return;
2834
+ case "move":
2835
+ if (!this.touchDown) return;
2836
+ this.maxDistance = Math.max(this.maxDistance, Math.hypot(x - this.downX, y - this.downY));
2837
+ if (now - this.lastMoveAt < 50) return;
2838
+ this.lastMoveAt = now;
2839
+ push(RrwebEvents.touchMove(now, x, y));
2840
+ return;
2841
+ default:
2842
+ if (!this.touchDown) return;
2843
+ this.touchDown = false;
2844
+ this.unchanged = 0;
2845
+ this.maxDistance = Math.max(this.maxDistance, Math.hypot(x - this.downX, y - this.downY));
2846
+ push(RrwebEvents.interaction(now, RrwebEvents.TOUCH_END, x, y));
2847
+ if (phase === "up" && this.maxDistance <= 10 && now - this.downAt < 1e3) push(RrwebEvents.interaction(now, RrwebEvents.CLICK, x, y));
2848
+ if (this.capturing) this.captureSoon = true;
2849
+ else this.schedule(TOUCH_SETTLE_MS, true);
2850
+ }
2851
+ }
2852
+ clearTimer() {
2853
+ if (this.timer) clearTimeout(this.timer);
2854
+ this.timer = null;
2855
+ }
2856
+ /** Schedules the next capture `delay` ms from now, or keeps an earlier one when `ifSooner`. */
2857
+ schedule(delay, ifSooner = false) {
2858
+ const due = this.now() + delay;
2859
+ if (ifSooner && this.timer && this.timerDue <= due) return;
2860
+ this.clearTimer();
2861
+ this.timerDue = due;
2862
+ const gen = this.generation;
2863
+ this.timer = setTimeout(() => {
2864
+ this.timer = null;
2865
+ if (gen === this.generation) void this.tick();
2866
+ }, delay);
2867
+ }
2868
+ async tick() {
2869
+ if (this.sid === null || !this.foreground || this.capturing) return;
2870
+ if (this.touchDown) {
2871
+ this.schedule(this.intervalMs);
2872
+ return;
2873
+ }
2874
+ this.capturing = true;
2875
+ this.captureSoon = false;
2876
+ const gen = this.generation;
2877
+ let frame = null;
2878
+ try {
2879
+ frame = await this.opts.capturer(await this.opts.captureOptions());
2880
+ } catch (err) {
2881
+ this.opts.onError?.(err);
2882
+ }
2883
+ if (gen !== this.generation) return;
2884
+ this.capturing = false;
2885
+ if (frame) this.onFrame(frame, this.now());
2886
+ if (this.sid !== null && this.foreground) this.schedule(this.captureSoon ? TOUCH_SETTLE_MS : this.currentIntervalMs);
2887
+ }
2888
+ onFrame(frame, at) {
2889
+ const id = this.sid;
2890
+ if (id === null || !(frame.width > 0) || !(frame.height > 0) || !frame.base64) return;
2891
+ const sizeChanged = frame.width !== this.viewportWidth || frame.height !== this.viewportHeight;
2892
+ const keyframe = this.needKeyframe || !this.hasKeyframe || sizeChanged;
2893
+ if (!keyframe && this.lastFrame === frame.base64) {
2894
+ this.unchanged++;
2895
+ return;
2896
+ }
2897
+ this.unchanged = 0;
2898
+ this.lastFrame = frame.base64;
2899
+ this.originX = frame.originX ?? 0;
2900
+ this.originY = frame.originY ?? 0;
2901
+ this.viewportWidth = frame.width;
2902
+ this.viewportHeight = frame.height;
2903
+ const url = RrwebEvents.dataUrl(frame.base64);
2904
+ const up = this.opts.uploader;
2905
+ if (!keyframe) return up.push(id, RrwebEvents.frame(at, url));
2906
+ this.needKeyframe = false;
2907
+ up.push(id, RrwebEvents.meta(at, this.opts.href(), frame.width, frame.height));
2908
+ up.push(id, RrwebEvents.fullSnapshot(at, url, frame.width, frame.height));
2909
+ if (!this.hasKeyframe) {
2910
+ this.hasKeyframe = true;
2911
+ for (const [ts, payload] of this.pendingCustom) up.push(id, RrwebEvents.custom(Math.max(ts, at), "guidely", payload));
2912
+ this.pendingCustom = [];
2913
+ }
2914
+ }
2915
+ };
2916
+
2495
2917
  // src/core/storage.ts
2496
2918
  var PersistentStore = class {
2497
2919
  constructor(backing, onError = () => {
@@ -2545,7 +2967,7 @@ var PersistentStore = class {
2545
2967
 
2546
2968
  // src/core/client.ts
2547
2969
  var SDK_NAME = "@guidelytech/react-native";
2548
- var SDK_VERSION = "0.12.0";
2970
+ var SDK_VERSION = "0.13.0";
2549
2971
  var DEFAULT_API_URL = "https://guidelytech.com";
2550
2972
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
2551
2973
  var CACHED_START_MS = 1500;
@@ -2606,6 +3028,10 @@ var GuidelyClient = class {
2606
3028
  __publicField(this, "lastAudiences", /* @__PURE__ */ new Set());
2607
3029
  __publicField(this, "backgroundedAt", null);
2608
3030
  __publicField(this, "messenger", null);
3031
+ __publicField(this, "replayUploader", null);
3032
+ __publicField(this, "recorder", null);
3033
+ /** Replay decision for the current session (sampled once; forced by start/stopSessionRecording). */
3034
+ __publicField(this, "replayDecision", null);
2609
3035
  __publicField(this, "ui", { steps: [], checklists: [], survey: null, resourceCenterOpen: false, theme: {}, messenger: false });
2610
3036
  __publicField(this, "uiListeners", /* @__PURE__ */ new Set());
2611
3037
  /** Feature flags for this environment: `guidely.flags.isEnabled("new-checkout")`. */
@@ -2757,6 +3183,7 @@ var GuidelyClient = class {
2757
3183
  if (this.destroyed) return;
2758
3184
  this.isReady = true;
2759
3185
  if (this.config) this.emitter.emit("ready", this.config);
3186
+ this.syncReplay();
2760
3187
  this.notify();
2761
3188
  this.evaluate("ready");
2762
3189
  } catch (err) {
@@ -2783,6 +3210,7 @@ var GuidelyClient = class {
2783
3210
  this.syncMessenger();
2784
3211
  if (this.hydrated) this.checkLaunch();
2785
3212
  this.emitter.emit("config", this.config);
3213
+ this.syncReplay();
2786
3214
  this.log("config loaded", this.config.configVersion, `${this.config.guides.length} guides`);
2787
3215
  return true;
2788
3216
  }
@@ -2835,6 +3263,7 @@ var GuidelyClient = class {
2835
3263
  void this.outbox?.flush();
2836
3264
  void this.store.flush();
2837
3265
  void this.flags.flush();
3266
+ this.recorder?.pause();
2838
3267
  this.messenger?.setForeground(false);
2839
3268
  } else if (state === "active" && this.backgroundedAt !== null) {
2840
3269
  this.messenger?.setForeground(true);
@@ -2844,7 +3273,10 @@ var GuidelyClient = class {
2844
3273
  this.session = newSession();
2845
3274
  this.persistSession();
2846
3275
  this.firedDelays.clear();
3276
+ this.recorder?.stop(false);
2847
3277
  }
3278
+ this.recorder?.resume();
3279
+ this.syncReplay();
2848
3280
  this.cameToForeground();
2849
3281
  void this.flags.refresh();
2850
3282
  if (Date.now() - this.configFetchedAt > this.options.configRefreshMs && this.api.health.canSend()) void this.refresh();
@@ -2867,6 +3299,9 @@ var GuidelyClient = class {
2867
3299
  this.queued = [];
2868
3300
  this.messenger?.destroy();
2869
3301
  this.messenger = null;
3302
+ this.recorder?.stop(!this.analyticsEnabled());
3303
+ this.recorder = null;
3304
+ this.replayUploader?.destroy();
2870
3305
  this.visibilityWatchers.forEach((stop) => stop());
2871
3306
  this.visibilityWatchers.clear();
2872
3307
  this.timers.forEach((t) => clearTimeout(t));
@@ -2981,6 +3416,7 @@ var GuidelyClient = class {
2981
3416
  this.store.set(KEYS.anon, this.anonymousId);
2982
3417
  this.state = emptyState();
2983
3418
  this.persistState();
3419
+ this.recorder?.stop(false);
2984
3420
  this.session = newSession();
2985
3421
  this.persistSession();
2986
3422
  this.lastAudiences = /* @__PURE__ */ new Set();
@@ -2988,6 +3424,7 @@ var GuidelyClient = class {
2988
3424
  this.firstVisit = true;
2989
3425
  this.exposures.clear();
2990
3426
  this.syncFlagAttributes();
3427
+ this.syncReplay();
2991
3428
  this.emitter.emit("identify", { userId: null, anonymousId: this.anonymousId });
2992
3429
  this.notify();
2993
3430
  if (this.isReady) this.evaluate("identify");
@@ -3002,6 +3439,7 @@ var GuidelyClient = class {
3002
3439
  this.whenHydrated(() => {
3003
3440
  if (consent === "denied") this.queue?.clear();
3004
3441
  else if (consent === "granted") void this.queue?.flush();
3442
+ this.syncReplay();
3005
3443
  });
3006
3444
  }
3007
3445
  analyticsEnabled() {
@@ -3030,6 +3468,7 @@ var GuidelyClient = class {
3030
3468
  const locale = extra.guideId ? this.guideLocales.get(extra.guideId) : void 0;
3031
3469
  if (locale) event.properties = { ...event.properties ?? {}, locale };
3032
3470
  this.queue.enqueue(event);
3471
+ this.recorder?.addGuidelyEvent(name, event.customName, event.guideId);
3033
3472
  this.emitter.emit("track", event);
3034
3473
  return event;
3035
3474
  }
@@ -3067,6 +3506,7 @@ var GuidelyClient = class {
3067
3506
  }
3068
3507
  this.screenName = name;
3069
3508
  this.targets.setScreen(name);
3509
+ if (changed) this.recorder?.onScreen();
3070
3510
  if (changed) this.syncFlagAttributes();
3071
3511
  const url = this.currentUrl();
3072
3512
  if (changed) {
@@ -3101,6 +3541,112 @@ var GuidelyClient = class {
3101
3541
  if (this.isReady) this.evaluate("click", { targetId });
3102
3542
  }
3103
3543
  // ---------------------------------------------------------------------------
3544
+ // Session replay
3545
+ // ---------------------------------------------------------------------------
3546
+ replaySettings() {
3547
+ const r = this.config?.settings?.replay;
3548
+ if (!r?.enabled || !this.options.sessionReplay?.enabled || !this.bridge.capturer) return null;
3549
+ return r;
3550
+ }
3551
+ /** Reconciles recording with config, consent, readiness, the session and its sample decision. Idempotent. */
3552
+ syncReplay() {
3553
+ if (this.destroyed || !this.hydrated) return;
3554
+ const r = this.isReady && this.analyticsEnabled() ? this.replaySettings() : null;
3555
+ const id = this.session.id;
3556
+ if (r && this.replayDecision?.sessionId !== id) {
3557
+ this.replayDecision = { sessionId: id, record: rolloutBucket(id, "replay") < (r.sampleRate ?? 100) };
3558
+ this.log(`replay ${this.replayDecision.record ? "sampled" : "not sampled"} for session ${id}`);
3559
+ }
3560
+ if (!r || !this.replayDecision?.record) {
3561
+ if (this.recorder?.isRecording) this.recorder.stop(!this.analyticsEnabled());
3562
+ return;
3563
+ }
3564
+ const rec = this.recorder ?? this.createRecorder();
3565
+ if (rec && rec.sessionId !== id) {
3566
+ this.log(`recording session ${id}`);
3567
+ rec.start(id);
3568
+ }
3569
+ }
3570
+ createRecorder() {
3571
+ const capturer = this.bridge.capturer;
3572
+ if (!capturer) return null;
3573
+ const uploader = this.replayUploader ?? (this.replayUploader = new ReplayUploader({
3574
+ url: () => this.api.url("/replay"),
3575
+ fetch: () => this.options.fetch ?? (typeof fetch === "function" ? fetch : null),
3576
+ health: this.api.health,
3577
+ meta: () => {
3578
+ const rec2 = this.recorder;
3579
+ return {
3580
+ anonymousId: this.anonymousId,
3581
+ userId: this.user?.id || null,
3582
+ ...this.collectUrls() && this.screenName ? { url: this.currentUrl() } : {},
3583
+ device: { type: this.bridge.device.deviceType, os: this.bridge.device.platform },
3584
+ ...rec2 && rec2.viewportWidth > 0 && rec2.viewportHeight > 0 ? { viewport: { width: rec2.viewportWidth, height: rec2.viewportHeight } } : {},
3585
+ sdk: { name: SDK_NAME, version: SDK_VERSION }
3586
+ };
3587
+ },
3588
+ onDisabled: (sid) => {
3589
+ if (sid !== this.session.id) return;
3590
+ this.log(`replay disabled by the server for session ${sid}`);
3591
+ this.replayDecision = { sessionId: sid, record: false };
3592
+ this.recorder?.stop(true);
3593
+ },
3594
+ log: (m) => this.log(m)
3595
+ }));
3596
+ const opts = this.options.sessionReplay ?? {};
3597
+ const rec = new SessionRecorder({
3598
+ capturer,
3599
+ uploader,
3600
+ intervalMs: opts.captureIntervalMs ?? 1e3,
3601
+ captureOptions: () => this.replayCaptureOptions(),
3602
+ href: () => this.currentUrl(),
3603
+ onError: (err) => this.reportError(err)
3604
+ });
3605
+ if (this.backgroundedAt !== null) rec.pause();
3606
+ this.recorder = rec;
3607
+ return rec;
3608
+ }
3609
+ async replayCaptureOptions() {
3610
+ const r = this.config?.settings?.replay;
3611
+ const opts = this.options.sessionReplay ?? {};
3612
+ const ids = new Set((r?.blockSelectors ?? []).map((sel) => guidelyIdFromSelector(sel)).filter((id) => !!id));
3613
+ const rects = await Promise.all(Array.from(ids, (id) => this.targets.measure(id)));
3614
+ return { maskAllText: !!opts.maskAllText || !!r?.maskAllText, maskAllImages: !!opts.maskAllImages, blockRects: rects.filter((rect) => !!rect) };
3615
+ }
3616
+ /**
3617
+ * Records this session even when it was not sampled (still needs `sessionReplay.enabled`, replay
3618
+ * enabled for the project, analytics consent and the native replay module). Sticks until the session ends.
3619
+ */
3620
+ startSessionRecording() {
3621
+ this.whenHydrated(() => {
3622
+ this.replayDecision = { sessionId: this.session.id, record: true };
3623
+ this.syncReplay();
3624
+ });
3625
+ }
3626
+ /** Stops recording for the rest of this session; what was recorded is uploaded. */
3627
+ stopSessionRecording() {
3628
+ this.whenHydrated(() => {
3629
+ this.replayDecision = { sessionId: this.session.id, record: false };
3630
+ this.recorder?.stop(false);
3631
+ });
3632
+ }
3633
+ /** True while this session is being recorded. */
3634
+ isRecording() {
3635
+ return !!this.recorder?.isRecording;
3636
+ }
3637
+ /** The current analytics session id (also the replay id). */
3638
+ getSessionId() {
3639
+ return this.session.id;
3640
+ }
3641
+ /** True when touches should be passed to {@link recordTouch}. */
3642
+ isRecordingTouches() {
3643
+ return !!this.recorder?.isRecording;
3644
+ }
3645
+ /** A primary-pointer touch on the app, in window points (the provider's touch observer calls this). */
3646
+ recordTouch(phase, x, y) {
3647
+ this.recorder?.touch(phase, x, y);
3648
+ }
3649
+ // ---------------------------------------------------------------------------
3104
3650
  // Product analytics (autocapture)
3105
3651
  // ---------------------------------------------------------------------------
3106
3652
  captureAllowed() {
@@ -3147,7 +3693,7 @@ var GuidelyClient = class {
3147
3693
  }
3148
3694
  if (this.destroyed) return;
3149
3695
  const platform = this.bridge.device.platform;
3150
- const maskAll = !!this.config?.settings?.replay?.maskAllText;
3696
+ const maskAll = !!this.config?.settings?.replay?.maskAllText || !!this.options.sessionReplay?.maskAllText;
3151
3697
  const eventUrl = this.collectUrls() && url ? url : void 0;
3152
3698
  if (on.clicks && el) this.enqueue("click", { url: eventUrl, properties: { ...tapProperties(el, tap.x, tap.y, platform, maskAll) } });
3153
3699
  const count = this.rage.add(tap.x, tap.y, t);
@@ -6106,8 +6652,9 @@ function rcLauncherLabel(client) {
6106
6652
 
6107
6653
  // src/ui/Autocapture.tsx
6108
6654
  var import_jsx_runtime11 = require("react/jsx-runtime");
6655
+ var GUIDELY_MASK_NATIVE_ID = "guidely-mask";
6109
6656
  function GuidelyMask({ children, ...rest }) {
6110
- return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native11.View, { ...rest, children });
6657
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native11.View, { collapsable: false, ...rest, nativeID: GUIDELY_MASK_NATIVE_ID, children });
6111
6658
  }
6112
6659
  var RESOLVE = {
6113
6660
  isIgnored: (type) => type === GuidelyOverlay,
@@ -6118,6 +6665,18 @@ function useTapObserver(client) {
6118
6665
  clientRef.current = client;
6119
6666
  return (0, import_react8.useMemo)(() => {
6120
6667
  const tracker = new TapTracker();
6668
+ let replayPointer = null;
6669
+ const replay = (phase, e) => {
6670
+ const c = clientRef.current;
6671
+ const n = e.nativeEvent;
6672
+ if (!c?.isRecordingTouches()) return void (replayPointer = null);
6673
+ if (phase === "down") {
6674
+ if (replayPointer !== null) return;
6675
+ replayPointer = n.identifier;
6676
+ } else if (n.identifier !== replayPointer) return;
6677
+ if (phase === "up" || phase === "cancel") replayPointer = null;
6678
+ c.recordTouch(phase, n.pageX, n.pageY);
6679
+ };
6121
6680
  const guard = (fn) => (e) => {
6122
6681
  try {
6123
6682
  fn(e);
@@ -6127,12 +6686,17 @@ function useTapObserver(client) {
6127
6686
  };
6128
6687
  return {
6129
6688
  onTouchStartCapture: guard((e) => {
6689
+ replay("down", e);
6130
6690
  const n = e.nativeEvent;
6131
6691
  if (!clientRef.current?.isTapCaptureOn()) return tracker.cancel();
6132
6692
  tracker.begin(n.pageX, n.pageY, Date.now(), Array.isArray(n.touches) ? n.touches.length : 1, n.identifier);
6133
6693
  }),
6134
- onTouchMoveCapture: guard((e) => tracker.move(e.nativeEvent.pageX, e.nativeEvent.pageY, e.nativeEvent.identifier)),
6694
+ onTouchMoveCapture: guard((e) => {
6695
+ replay("move", e);
6696
+ tracker.move(e.nativeEvent.pageX, e.nativeEvent.pageY, e.nativeEvent.identifier);
6697
+ }),
6135
6698
  onTouchEndCapture: guard((e) => {
6699
+ replay("up", e);
6136
6700
  const n = e.nativeEvent;
6137
6701
  const tap = tracker.end(n.pageX, n.pageY, Date.now(), n.identifier);
6138
6702
  const c = clientRef.current;
@@ -6140,7 +6704,10 @@ function useTapObserver(client) {
6140
6704
  const r = resolveTapTarget(e, RESOLVE);
6141
6705
  if (!r.ignored) c.recordTap({ x: tap.x, y: tap.y, t: Date.now(), element: r.element, hit: r.hit });
6142
6706
  }),
6143
- onTouchCancelCapture: guard(() => tracker.cancel())
6707
+ onTouchCancelCapture: guard((e) => {
6708
+ replay("cancel", e);
6709
+ tracker.cancel();
6710
+ })
6144
6711
  };
6145
6712
  }, []);
6146
6713
  }
@@ -6234,9 +6801,15 @@ function defaultBridge(appVersion) {
6234
6801
  },
6235
6802
  openExternal: (url) => void import_react_native13.Linking.openURL(url).catch(() => {
6236
6803
  }),
6237
- windowSize: () => import_react_native13.Dimensions.get("window")
6804
+ windowSize: () => import_react_native13.Dimensions.get("window"),
6805
+ capturer: nativeCapturer()
6238
6806
  };
6239
6807
  }
6808
+ function nativeCapturer() {
6809
+ const mod = import_react_native13.NativeModules.GuidelyReplay;
6810
+ if (!mod || typeof mod.capture !== "function") return void 0;
6811
+ return (options) => mod.capture(options);
6812
+ }
6240
6813
  function GuidelyProvider({ children, user, insets, launcherOffset = 72, flagsPayload, flagAttributes, ...options }) {
6241
6814
  const [client, setClient] = (0, import_react10.useState)(null);
6242
6815
  const optionsRef = (0, import_react10.useRef)(options);
@@ -6306,7 +6879,7 @@ function GuidelyProvider({ children, user, insets, launcherOffset = 72, flagsPay
6306
6879
  const flags = (0, import_react10.useMemo)(() => ({ client: client?.flags ?? null, bootstrap }), [client, bootstrap]);
6307
6880
  const tapObserver = useTapObserver(client);
6308
6881
  return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(GuidelyContext.Provider, { value: client, children: /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(FlagsContext.Provider, { value: flags, children: [
6309
- options.autocapture === false ? children : /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_react_native13.View, { style: styles10.root, ...tapObserver, children }),
6882
+ options.autocapture === false && !options.sessionReplay?.enabled ? children : /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_react_native13.View, { style: styles10.root, ...tapObserver, children }),
6310
6883
  client ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(GuidelyOverlay, { client, insets, launcherOffset }) : null
6311
6884
  ] }) });
6312
6885
  }
@@ -6326,6 +6899,9 @@ var NOOP_RESULTS = {
6326
6899
  getMessenger: null,
6327
6900
  isGuideCompleted: false,
6328
6901
  isClientReady: false,
6902
+ isRecording: false,
6903
+ isRecordingTouches: false,
6904
+ getSessionId: "",
6329
6905
  getScreen: "",
6330
6906
  on: () => {
6331
6907
  },
@@ -6460,8 +7036,11 @@ function useActiveGuide() {
6460
7036
  InMemoryStickyBucketStore,
6461
7037
  Messenger,
6462
7038
  RageClickDetector,
7039
+ ReplayUploader,
7040
+ RrwebEvents,
6463
7041
  SDK_NAME,
6464
7042
  SDK_VERSION,
7043
+ SessionRecorder,
6465
7044
  TapTracker,
6466
7045
  TargetRegistry,
6467
7046
  createFlagsClient,