@oneuptime/react-native-replay 14.0.10 → 14.0.12

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
@@ -59,7 +59,7 @@ var SESSION_REPLAY_MAX_OFFLINE_DELAY_MS = 3 * 24 * 60 * 60 * 1e3;
59
59
  var MOBILE_CAPTURE_INTERVAL_MS = 500;
60
60
  var MOBILE_RECORDER_KIND = "rn-view-tree";
61
61
  var SYNTHETIC_RRWEB_VERSION = "synthetic-1";
62
- var RECORDER_VERSION = true ? "14.0.10" : "0.0.0-dev";
62
+ var RECORDER_VERSION = true ? "14.0.12" : "0.0.0-dev";
63
63
  var SESSION_REPLAY_MOBILE_RECORDER_CAPABILITIES = [
64
64
  "mobile-view-tree",
65
65
  "mobile-touch-events",
@@ -111,6 +111,7 @@ var IDENTIFY_CUSTOM_EVENT_TAG = "oneuptime.identify";
111
111
  var TAGS_CUSTOM_EVENT_TAG = "oneuptime.tags";
112
112
  var VISIBILITY_CUSTOM_EVENT_TAG = "oneuptime.visibility";
113
113
  var TOUCH_CUSTOM_EVENT_TAG = "oneuptime.touch";
114
+ var SESSION_ROTATED_CUSTOM_EVENT_TAG = "oneuptime.session-rotated";
114
115
 
115
116
  // src/Sanitize.ts
116
117
  var MAX_TRAIT_KEYS = 20;
@@ -3081,6 +3082,19 @@ var MobileReplayRecorder = class {
3081
3082
  __publicField(this, "pendingStartCancellation", null);
3082
3083
  __publicField(this, "identityPersisted", false);
3083
3084
  __publicField(this, "sessionLastActivityUnixMs", 0);
3085
+ /*
3086
+ * The session went the idle window without activity, was sealed where it
3087
+ * ended, and nobody has come back since.
3088
+ *
3089
+ * Rotating on the spot instead (the old behaviour) started a session
3090
+ * nobody was in: an app left open in the foreground with the screen on
3091
+ * re-snapshots every minute, so it filed a new session every half hour
3092
+ * for as long as it stayed open. Now nothing is captured, uploaded or
3093
+ * handed to the host as a session id until the user is back - a touch,
3094
+ * the app returning to the foreground, or an explicit captureSession() -
3095
+ * and that starts the next session (see maybeRotateSession).
3096
+ */
3097
+ __publicField(this, "sealedForIdle", false);
3084
3098
  __publicField(this, "rotatingSession", false);
3085
3099
  __publicField(this, "rotationPromise", null);
3086
3100
  __publicField(this, "appStateQueue", Promise.resolve());
@@ -3168,6 +3182,7 @@ var MobileReplayRecorder = class {
3168
3182
  this.appStateQueue = Promise.resolve();
3169
3183
  this.rotationPromise = null;
3170
3184
  this.rotatingSession = false;
3185
+ this.sealedForIdle = false;
3171
3186
  this.policyRefreshPromise = null;
3172
3187
  this.lastPolicyRefreshAtUnixMs = 0;
3173
3188
  this.forceFullSnapshot = true;
@@ -3340,12 +3355,14 @@ var MobileReplayRecorder = class {
3340
3355
  this.diagnosticsStatus = "stopped";
3341
3356
  return;
3342
3357
  }
3343
- this.appendCustom(
3344
- VISIBILITY_CUSTOM_EVENT_TAG,
3345
- { state: "stopped" },
3346
- void 0,
3347
- true
3348
- );
3358
+ if (this.rotationReason(this.now()) !== "idle") {
3359
+ this.appendCustom(
3360
+ VISIBILITY_CUSTOM_EVENT_TAG,
3361
+ { state: "stopped" },
3362
+ void 0,
3363
+ true
3364
+ );
3365
+ }
3349
3366
  this.haltCapture();
3350
3367
  await this.closeCurrent(true);
3351
3368
  if (this.uploadActive) {
@@ -3508,6 +3525,9 @@ var MobileReplayRecorder = class {
3508
3525
  return;
3509
3526
  }
3510
3527
  this.route = next;
3528
+ if (this.sealedForIdle) {
3529
+ return;
3530
+ }
3511
3531
  this.appendCustom(
3512
3532
  ROUTE_CUSTOM_EVENT_TAG,
3513
3533
  {
@@ -3526,7 +3546,7 @@ var MobileReplayRecorder = class {
3526
3546
  if (!this.running || !this.acceptingEvents) {
3527
3547
  return;
3528
3548
  }
3529
- await this.maybeRotateSession(this.now());
3549
+ await this.maybeRotateForUser(this.now());
3530
3550
  const safeReason2 = truncate(
3531
3551
  (typeof reason === "string" ? reason.trim() : "") || "manual",
3532
3552
  MAX_CAPTURE_REASON_LENGTH
@@ -3543,6 +3563,9 @@ var MobileReplayRecorder = class {
3543
3563
  return;
3544
3564
  }
3545
3565
  await this.maybeRotateSession(this.now());
3566
+ if (this.sealedForIdle) {
3567
+ return;
3568
+ }
3546
3569
  const rawName = error instanceof Error && typeof error.name === "string" ? error.name.trim() : "";
3547
3570
  const name = SAFE_ERROR_NAMES.has(rawName) ? rawName : "Error";
3548
3571
  this.appendCustom(
@@ -3593,9 +3616,9 @@ var MobileReplayRecorder = class {
3593
3616
  this.lastTouchMoveAtUnixMs = touch.timestamp;
3594
3617
  }
3595
3618
  const nowUnixMs = this.now();
3596
- if (this.rotatingSession || this.rotationReason(nowUnixMs)) {
3619
+ if (this.rotatingSession || this.sealedForIdle || this.rotationReason(nowUnixMs)) {
3597
3620
  this.diagnostic("touch-dropped-session-rotation");
3598
- void this.maybeRotateSession(nowUnixMs);
3621
+ void this.maybeRotateForUser(nowUnixMs);
3599
3622
  return;
3600
3623
  }
3601
3624
  this.appendEvent(
@@ -3643,9 +3666,14 @@ var MobileReplayRecorder = class {
3643
3666
  )
3644
3667
  };
3645
3668
  }
3646
- /** Current consented replay session for host trace/log correlation. */
3669
+ /**
3670
+ * Current consented replay session for host trace/log correlation. None
3671
+ * while the session has ended idle and nobody is back: a background poll
3672
+ * on an idle app is no part of a recording that ended at the user's last
3673
+ * activity.
3674
+ */
3647
3675
  getSessionId() {
3648
- return this.running && this.identityPersisted ? this.identity?.sessionId ?? null : null;
3676
+ return this.running && this.identityPersisted && !this.sealedForIdle ? this.identity?.sessionId ?? null : null;
3649
3677
  }
3650
3678
  /** Fires immediately when a session exists and again on rotate/clear. */
3651
3679
  onSessionChange(listener) {
@@ -3700,7 +3728,7 @@ var MobileReplayRecorder = class {
3700
3728
  }
3701
3729
  async captureTick(force = false) {
3702
3730
  const generation = this.lifecycleGeneration;
3703
- if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.captureGeneration === generation || !this.options) {
3731
+ if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
3704
3732
  return;
3705
3733
  }
3706
3734
  this.captureGeneration = generation;
@@ -3715,7 +3743,7 @@ var MobileReplayRecorder = class {
3715
3743
  return;
3716
3744
  }
3717
3745
  await this.maybeRotateSession(nowUnixMs);
3718
- if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation)) {
3746
+ if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
3719
3747
  return;
3720
3748
  }
3721
3749
  const capturedSessionId = this.identity?.sessionId;
@@ -3724,7 +3752,6 @@ var MobileReplayRecorder = class {
3724
3752
  return;
3725
3753
  }
3726
3754
  this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
3727
- const size = getViewport();
3728
3755
  const shouldForce = force || this.forceFullSnapshot || nowUnixMs - this.lastFullSnapshotAtUnixMs >= SESSION_REPLAY_CHECKOUT_INTERVAL_MS;
3729
3756
  if (shouldForce && !this.chunkBuffer.isEmpty()) {
3730
3757
  await this.closeCurrent(false);
@@ -3732,22 +3759,7 @@ var MobileReplayRecorder = class {
3732
3759
  if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== capturedSessionId) {
3733
3760
  return;
3734
3761
  }
3735
- const capture = this.serializer.capture(tree, {
3736
- timestamp: nowUnixMs,
3737
- url: toAppUrl(this.options.mobileAppIdentifier, this.route),
3738
- viewportWidth: size.width,
3739
- viewportHeight: size.height,
3740
- forceFullSnapshot: shouldForce
3741
- });
3742
- this.droppedEvents += capture.droppedNodes;
3743
- this.chunkBuffer.appendMany(capture.events, {
3744
- route: toAppUrl(this.options.mobileAppIdentifier, this.route),
3745
- fidelityNotices: capture.fidelityNotices
3746
- });
3747
- if (capture.hasFullSnapshot) {
3748
- this.lastFullSnapshotAtUnixMs = nowUnixMs;
3749
- this.forceFullSnapshot = false;
3750
- }
3762
+ this.appendCapture(tree, this.options, nowUnixMs, shouldForce);
3751
3763
  if (this.chunkBuffer.shouldFlush()) {
3752
3764
  await this.closeCurrent(false);
3753
3765
  }
@@ -3764,6 +3776,26 @@ var MobileReplayRecorder = class {
3764
3776
  }
3765
3777
  }
3766
3778
  }
3779
+ appendCapture(tree, options, nowUnixMs, forceFullSnapshot) {
3780
+ const size = getViewport();
3781
+ const url = toAppUrl(options.mobileAppIdentifier, this.route);
3782
+ const capture = this.serializer.capture(tree, {
3783
+ timestamp: nowUnixMs,
3784
+ url,
3785
+ viewportWidth: size.width,
3786
+ viewportHeight: size.height,
3787
+ forceFullSnapshot
3788
+ });
3789
+ this.droppedEvents += capture.droppedNodes;
3790
+ this.chunkBuffer.appendMany(capture.events, {
3791
+ route: url,
3792
+ fidelityNotices: capture.fidelityNotices
3793
+ });
3794
+ if (capture.hasFullSnapshot) {
3795
+ this.lastFullSnapshotAtUnixMs = nowUnixMs;
3796
+ this.forceFullSnapshot = false;
3797
+ }
3798
+ }
3767
3799
  appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
3768
3800
  this.appendEvent(
3769
3801
  createCustomEvent(tag, payload, this.now()),
@@ -3775,6 +3807,9 @@ var MobileReplayRecorder = class {
3775
3807
  if (!this.running || !this.acceptingEvents && !allowWhenQuiesced) {
3776
3808
  return;
3777
3809
  }
3810
+ if (this.sealedForIdle) {
3811
+ return;
3812
+ }
3778
3813
  if (!Number.isFinite(event.timestamp)) {
3779
3814
  this.droppedEvents += 1;
3780
3815
  this.diagnostic("invalid-event-timestamp");
@@ -3803,7 +3838,7 @@ var MobileReplayRecorder = class {
3803
3838
  }
3804
3839
  }
3805
3840
  async closeCurrent(isFinal) {
3806
- const closesFinalChunkSlot = !isFinal && !this.rotatingSession && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
3841
+ const closesFinalChunkSlot = !isFinal && !this.rotatingSession && !this.sealedForIdle && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
3807
3842
  if (closesFinalChunkSlot) {
3808
3843
  this.rotatingSession = true;
3809
3844
  const rotationOptions = {};
@@ -3815,7 +3850,7 @@ var MobileReplayRecorder = class {
3815
3850
  }
3816
3851
  this.chunkBuffer.append(
3817
3852
  createCustomEvent(
3818
- "oneuptime.session-rotated",
3853
+ SESSION_ROTATED_CUSTOM_EVENT_TAG,
3819
3854
  { reason: "chunk-cap" },
3820
3855
  this.now()
3821
3856
  ),
@@ -4015,15 +4050,19 @@ var MobileReplayRecorder = class {
4015
4050
  if (!policyReady) {
4016
4051
  return;
4017
4052
  }
4018
- await this.maybeRotateSession(this.now());
4053
+ const sessionIdBeforeResume = this.identity?.sessionId;
4054
+ await this.maybeRotateSession(this.now(), true);
4019
4055
  if (!this.running || !this.isCurrentGeneration(generation)) {
4020
4056
  return;
4021
4057
  }
4022
4058
  this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
4023
- this.forceFullSnapshot = true;
4059
+ const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
4060
+ if (!openedOnSnapshot) {
4061
+ this.forceFullSnapshot = true;
4062
+ }
4024
4063
  this.startTimers();
4025
4064
  this.diagnosticsStatus = this.consentState() === "Unknown" ? "consent-required" : "recording";
4026
- await this.captureTick(true);
4065
+ await this.captureTick(!openedOnSnapshot);
4027
4066
  if (this.uploadActive && this.running) {
4028
4067
  this.transport?.resume();
4029
4068
  }
@@ -4303,9 +4342,12 @@ var MobileReplayRecorder = class {
4303
4342
  if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
4304
4343
  return;
4305
4344
  }
4345
+ if (!this.rotationPromise && !this.sealedForIdle && this.rotationReason(this.now()) === "idle") {
4346
+ await this.maybeRotateSession(this.now());
4347
+ }
4306
4348
  if (this.rotationPromise) {
4307
4349
  await this.rotationPromise;
4308
- } else {
4350
+ } else if (!this.sealedForIdle) {
4309
4351
  this.rotatingSession = true;
4310
4352
  const rotation = this.performSessionRotation(
4311
4353
  this.now(),
@@ -4640,16 +4682,24 @@ var MobileReplayRecorder = class {
4640
4682
  chunkIndex: 0
4641
4683
  };
4642
4684
  }
4685
+ /*
4686
+ * Idle before the duration cap, the order Common's SessionIdentity decides
4687
+ * them in. A session left for the idle window ended where it was left,
4688
+ * even when it has outlived the cap by the time anything notices, and
4689
+ * only an idle rotation dates its end there (see performSessionRotation):
4690
+ * called "duration", a session three hours old put in the background for
4691
+ * two more was sealed two hours after its footage.
4692
+ */
4643
4693
  rotationReason(nowUnixMs) {
4644
4694
  if (!this.identity) {
4645
4695
  return null;
4646
4696
  }
4647
- if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
4648
- return "duration";
4649
- }
4650
4697
  if (nowUnixMs - this.sessionLastActivityUnixMs >= SESSION_REPLAY_IDLE_ROLLOVER_MS) {
4651
4698
  return "idle";
4652
4699
  }
4700
+ if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
4701
+ return "duration";
4702
+ }
4653
4703
  return null;
4654
4704
  }
4655
4705
  runExternalActivity(nowUnixMs, action) {
@@ -4684,23 +4734,37 @@ var MobileReplayRecorder = class {
4684
4734
  }
4685
4735
  });
4686
4736
  }
4687
- async maybeRotateSession(nowUnixMs) {
4688
- if (this.rotationPromise) {
4737
+ /*
4738
+ * isUserBack: the caller knows the user is here - a touch, the app
4739
+ * returning to the foreground, an explicit captureSession(). An idle
4740
+ * session then rotates at once. Anything else (a capture tick, a route
4741
+ * change, a developer event) only finds out the session went idle, and
4742
+ * seals it to wait for them (see sealedForIdle).
4743
+ */
4744
+ async maybeRotateSession(nowUnixMs, isUserBack = false) {
4745
+ while (this.rotationPromise) {
4689
4746
  await this.rotationPromise;
4690
- return;
4747
+ if (!isUserBack || !this.sealedForIdle) {
4748
+ return;
4749
+ }
4691
4750
  }
4692
4751
  if (!this.running || this.rotatingSession || !this.identity || !this.config || !this.sessionStore || !this.options) {
4693
4752
  return;
4694
4753
  }
4695
- const reason = this.rotationReason(nowUnixMs);
4754
+ let reason;
4755
+ if (this.sealedForIdle) {
4756
+ if (!isUserBack) {
4757
+ return;
4758
+ }
4759
+ reason = "idle";
4760
+ } else {
4761
+ reason = this.rotationReason(nowUnixMs);
4762
+ }
4696
4763
  if (!reason) {
4697
4764
  return;
4698
4765
  }
4699
4766
  this.rotatingSession = true;
4700
- const operation = this.performSessionRotation(
4701
- nowUnixMs,
4702
- reason
4703
- );
4767
+ const operation = reason === "idle" && !isUserBack ? this.sealForIdle() : this.performSessionRotation(nowUnixMs, reason);
4704
4768
  this.rotationPromise = operation;
4705
4769
  try {
4706
4770
  await operation;
@@ -4711,8 +4775,60 @@ var MobileReplayRecorder = class {
4711
4775
  this.rotatingSession = false;
4712
4776
  }
4713
4777
  }
4778
+ /*
4779
+ * maybeRotateSession for a user who is here. On an app whose session
4780
+ * ended idle, the policy is brought up to date first: no capture tick has
4781
+ * refreshed it while the app sat idle, and the next session must not open
4782
+ * under one that has since changed (or turned replay off).
4783
+ */
4784
+ async maybeRotateForUser(nowUnixMs) {
4785
+ if (this.sealedForIdle && !this.rotatingSession) {
4786
+ const policyReady = await this.refreshRuntimePolicy(
4787
+ this.lifecycleGeneration,
4788
+ false,
4789
+ "periodic"
4790
+ );
4791
+ if (!policyReady) {
4792
+ return;
4793
+ }
4794
+ }
4795
+ await this.maybeRotateSession(nowUnixMs, true);
4796
+ }
4797
+ /*
4798
+ * End the session as idle, where it really ended, and wait for the user.
4799
+ *
4800
+ * Sealed with whatever footage is still buffered - dated by that footage,
4801
+ * never by now - and with no marker of its own: a marker stamped now
4802
+ * would date the session's end half an hour or more after anything the
4803
+ * player can show. With nothing buffered nothing is sent, and the server
4804
+ * ends the session at its last chunk.
4805
+ */
4806
+ async sealForIdle() {
4807
+ this.sealedForIdle = true;
4808
+ this.diagnostic("session-ended-idle", {
4809
+ idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
4810
+ });
4811
+ this.notifySessionChange();
4812
+ await this.closeCurrent(true);
4813
+ await this.closeQueue;
4814
+ if (!this.running) {
4815
+ return;
4816
+ }
4817
+ if (this.uploadActive) {
4818
+ await this.transport?.drain();
4819
+ } else {
4820
+ this.rollingBuffer.clear();
4821
+ }
4822
+ }
4714
4823
  async performSessionRotation(nowUnixMs, reason) {
4715
- this.appendCustom("oneuptime.session-rotated", { reason }, void 0, true);
4824
+ if (reason !== "idle") {
4825
+ this.appendCustom(
4826
+ SESSION_ROTATED_CUSTOM_EVENT_TAG,
4827
+ { reason },
4828
+ void 0,
4829
+ true
4830
+ );
4831
+ }
4716
4832
  await this.closeCurrent(true);
4717
4833
  await this.closeQueue;
4718
4834
  if (!this.running) {
@@ -4727,12 +4843,64 @@ var MobileReplayRecorder = class {
4727
4843
  return;
4728
4844
  }
4729
4845
  await this.completeSessionIdentityRotation(nowUnixMs, reason);
4846
+ if (reason === "idle" && this.running) {
4847
+ await this.openSessionAfterIdle();
4848
+ }
4849
+ }
4850
+ /*
4851
+ * The first things a session after an idle one records: a full snapshot
4852
+ * of the screen the user came back to, then the rotation marker - the
4853
+ * snapshot first, so chunk 0 is a seek anchor rather than a lone marker.
4854
+ *
4855
+ * Taken here, inside the rotation, rather than left to the next capture
4856
+ * tick: until the rotation settles, touches are dropped and every other
4857
+ * event waits for it (rotatingSession), so nothing can land ahead of the
4858
+ * snapshot. A user who has just come back is touching the screen, and
4859
+ * half a second of that was otherwise a chunk 0 with no screen in it.
4860
+ *
4861
+ * In the background or without a registered root there is no screen to
4862
+ * capture yet; the marker opens the session alone.
4863
+ */
4864
+ async openSessionAfterIdle() {
4865
+ const generation = this.lifecycleGeneration;
4866
+ const rootTag = this.rootTag;
4867
+ const options = this.options;
4868
+ const sessionId = this.identity?.sessionId;
4869
+ if (this.foreground && rootTag && options && sessionId) {
4870
+ const capturedAtUnixMs = this.now();
4871
+ let tree;
4872
+ try {
4873
+ tree = await this.nativeViewTree.captureViewTree(rootTag);
4874
+ } catch (error) {
4875
+ if (this.running && this.isCurrentGeneration(generation)) {
4876
+ this.disable("native-capture-failed", {
4877
+ name: error instanceof Error ? error.name : "Error"
4878
+ });
4879
+ this.haltCapture();
4880
+ }
4881
+ return;
4882
+ }
4883
+ if (!this.running || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== sessionId) {
4884
+ return;
4885
+ }
4886
+ if (this.foreground) {
4887
+ this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
4888
+ this.appendCapture(tree, options, capturedAtUnixMs, true);
4889
+ }
4890
+ }
4891
+ this.appendCustom(
4892
+ SESSION_ROTATED_CUSTOM_EVENT_TAG,
4893
+ { reason: "idle" },
4894
+ void 0,
4895
+ true
4896
+ );
4730
4897
  }
4731
4898
  async completeSessionIdentityRotation(nowUnixMs, reason) {
4732
4899
  if (!this.sessionStore || !this.config || !this.options) {
4733
4900
  return;
4734
4901
  }
4735
4902
  this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
4903
+ this.sealedForIdle = false;
4736
4904
  this.notifySessionChange();
4737
4905
  this.sampled = this.identityPersisted ? deterministicSample(
4738
4906
  this.identity.sessionId,