@oneuptime/react-native-replay 14.0.12 → 14.0.13
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/README.md +35 -6
- package/dist/index.cjs +426 -18
- package/dist/index.cjs.map +2 -2
- package/dist/index.js +427 -19
- package/dist/index.js.map +2 -2
- package/dist/types/Contract.d.ts +11 -0
- package/dist/types/Contract.d.ts.map +1 -1
- package/dist/types/MobileReplayRecorder.d.ts +28 -1
- package/dist/types/MobileReplayRecorder.d.ts.map +1 -1
- package/dist/types/Storage.d.ts +1 -0
- package/dist/types/Storage.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "react-native";
|
|
16
16
|
|
|
17
17
|
// src/MobileReplayRecorder.ts
|
|
18
|
-
import { AppState } from "react-native";
|
|
18
|
+
import { AppState, Keyboard } from "react-native";
|
|
19
19
|
|
|
20
20
|
// src/Contract.ts
|
|
21
21
|
var SESSION_REPLAY_WIRE_VERSION = 1;
|
|
@@ -51,6 +51,7 @@ var SESSION_REPLAY_CHECKOUT_INTERVAL_MS = 6e4;
|
|
|
51
51
|
var SESSION_REPLAY_ROLLING_BUFFER_MS = 6e4;
|
|
52
52
|
var SESSION_REPLAY_ROLLING_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
53
53
|
var SESSION_REPLAY_IDLE_ROLLOVER_MS = 30 * 6e4;
|
|
54
|
+
var SESSION_REPLAY_IDLE_PAUSE_MS = 5 * 6e4;
|
|
54
55
|
var SESSION_REPLAY_MAX_SESSION_MS = 4 * 60 * 6e4;
|
|
55
56
|
var SESSION_REPLAY_MAX_DECOMPRESSED_FRAME_BYTES = 8 * 1024 * 1024;
|
|
56
57
|
var MAX_SESSION_REPLAY_CHUNK_BYTES = 2 * 1024 * 1024;
|
|
@@ -59,7 +60,7 @@ var SESSION_REPLAY_MAX_OFFLINE_DELAY_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
|
59
60
|
var MOBILE_CAPTURE_INTERVAL_MS = 500;
|
|
60
61
|
var MOBILE_RECORDER_KIND = "rn-view-tree";
|
|
61
62
|
var SYNTHETIC_RRWEB_VERSION = "synthetic-1";
|
|
62
|
-
var RECORDER_VERSION = true ? "14.0.
|
|
63
|
+
var RECORDER_VERSION = true ? "14.0.13" : "0.0.0-dev";
|
|
63
64
|
var SESSION_REPLAY_MOBILE_RECORDER_CAPABILITIES = [
|
|
64
65
|
"mobile-view-tree",
|
|
65
66
|
"mobile-touch-events",
|
|
@@ -112,6 +113,8 @@ var TAGS_CUSTOM_EVENT_TAG = "oneuptime.tags";
|
|
|
112
113
|
var VISIBILITY_CUSTOM_EVENT_TAG = "oneuptime.visibility";
|
|
113
114
|
var TOUCH_CUSTOM_EVENT_TAG = "oneuptime.touch";
|
|
114
115
|
var SESSION_ROTATED_CUSTOM_EVENT_TAG = "oneuptime.session-rotated";
|
|
116
|
+
var IDLE_PAUSED_CUSTOM_EVENT_TAG = "oneuptime.idle-paused";
|
|
117
|
+
var IDLE_RESUMED_CUSTOM_EVENT_TAG = "oneuptime.idle-resumed";
|
|
115
118
|
|
|
116
119
|
// src/Sanitize.ts
|
|
117
120
|
var MAX_TRAIT_KEYS = 20;
|
|
@@ -2487,6 +2490,18 @@ var ReplaySessionStore = class {
|
|
|
2487
2490
|
};
|
|
2488
2491
|
return { ...this.identity };
|
|
2489
2492
|
}
|
|
2493
|
+
/*
|
|
2494
|
+
* Forget the stored session without starting the next one. A session
|
|
2495
|
+
* that ends while the recorder is paused for idle has no successor until
|
|
2496
|
+
* the person comes back, and its record - last written by its own final
|
|
2497
|
+
* chunk, so it looks recent - would otherwise let a relaunch in the
|
|
2498
|
+
* meantime carry on a session that is already over. The visitor id stays,
|
|
2499
|
+
* and so does the in-memory identity: nothing more is sent under it, and
|
|
2500
|
+
* the next rotate() writes the new session.
|
|
2501
|
+
*/
|
|
2502
|
+
async end() {
|
|
2503
|
+
await this.safeRemove(this.sessionKey);
|
|
2504
|
+
}
|
|
2490
2505
|
async clear() {
|
|
2491
2506
|
this.identity = null;
|
|
2492
2507
|
await Promise.all([
|
|
@@ -2945,6 +2960,7 @@ function isTouchPrivate(privacyMap, x2, y) {
|
|
|
2945
2960
|
var MAX_DIAGNOSTIC_EVENTS = 100;
|
|
2946
2961
|
var TOUCH_MOVE_SAMPLE_MS = 50;
|
|
2947
2962
|
var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
|
|
2963
|
+
var MAX_EVENTS_HELD_FOR_RESUME = 500;
|
|
2948
2964
|
var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
2949
2965
|
"AggregateError",
|
|
2950
2966
|
"Error",
|
|
@@ -3016,6 +3032,7 @@ var MobileReplayRecorder = class {
|
|
|
3016
3032
|
__publicField(this, "storage");
|
|
3017
3033
|
__publicField(this, "nativeViewTree");
|
|
3018
3034
|
__publicField(this, "appState");
|
|
3035
|
+
__publicField(this, "keyboard");
|
|
3019
3036
|
__publicField(this, "now");
|
|
3020
3037
|
__publicField(this, "serializer", new ViewTreeSerializer());
|
|
3021
3038
|
__publicField(this, "chunkBuffer", new ReplayChunkBuffer());
|
|
@@ -3091,8 +3108,10 @@ var MobileReplayRecorder = class {
|
|
|
3091
3108
|
* re-snapshots every minute, so it filed a new session every half hour
|
|
3092
3109
|
* for as long as it stayed open. Now nothing is captured, uploaded or
|
|
3093
3110
|
* 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
|
|
3095
|
-
* and that starts the next session (see
|
|
3111
|
+
* the soft keyboard, the app returning to the foreground, or an explicit
|
|
3112
|
+
* captureSession() - and that starts the next session (see
|
|
3113
|
+
* maybeRotateSession). In the foreground the session was almost always
|
|
3114
|
+
* paused for idle first (idlePause), so its footage already ended there.
|
|
3096
3115
|
*/
|
|
3097
3116
|
__publicField(this, "sealedForIdle", false);
|
|
3098
3117
|
__publicField(this, "rotatingSession", false);
|
|
@@ -3111,9 +3130,58 @@ var MobileReplayRecorder = class {
|
|
|
3111
3130
|
__publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
|
|
3112
3131
|
__publicField(this, "unidentifiedTouchCount", 0);
|
|
3113
3132
|
__publicField(this, "unidentifiedTouchPrivate", false);
|
|
3133
|
+
/*
|
|
3134
|
+
* When the person last did something: a touch (any touch - one inside a
|
|
3135
|
+
* ReplayMask is still someone using the app, even though it is never
|
|
3136
|
+
* recorded), the app coming back to the foreground, or the app starting.
|
|
3137
|
+
* Not sessionLastActivityUnixMs, which every recorded event moves: a
|
|
3138
|
+
* route change, a track() call or a JS error the app raises by itself is
|
|
3139
|
+
* the app, not the person, and a polling screen would otherwise never
|
|
3140
|
+
* look idle.
|
|
3141
|
+
*/
|
|
3142
|
+
__publicField(this, "lastInputUnixMs", 0);
|
|
3143
|
+
/*
|
|
3144
|
+
* The idle pause (SESSION_REPLAY_IDLE_PAUSE_MS, #4208). Set when the
|
|
3145
|
+
* capture tick finds nobody has touched the app for that long: the open
|
|
3146
|
+
* chunk closes with an idle-paused marker as its last event, the capture
|
|
3147
|
+
* timer stops, and from then on nothing is recorded - no view-tree
|
|
3148
|
+
* sample, no checkout, no chunk, and every event the app emits is
|
|
3149
|
+
* dropped rather than queued. The 15-second flush timer is the only one
|
|
3150
|
+
* left, and all it does is end the session (sealForIdle, #4207) if nobody
|
|
3151
|
+
* comes back inside SESSION_REPLAY_IDLE_ROLLOVER_MS.
|
|
3152
|
+
*
|
|
3153
|
+
* It exists because the recorder used to go on sampling the view tree
|
|
3154
|
+
* every 500 ms, with a full checkout every minute, for the whole half
|
|
3155
|
+
* hour before the idle rollover - the phone's CPU and battery and the
|
|
3156
|
+
* customer's upload and storage, all spent on footage of nobody.
|
|
3157
|
+
*
|
|
3158
|
+
* Input inside that window - a touch, the soft keyboard, the app
|
|
3159
|
+
* returning to the foreground - resumes the same session and tab on a
|
|
3160
|
+
* fresh full snapshot with an idle-resumed marker directly behind it
|
|
3161
|
+
* (idleResumeBacklog), at the next chunk index.
|
|
3162
|
+
*/
|
|
3163
|
+
__publicField(this, "idlePause", null);
|
|
3164
|
+
/*
|
|
3165
|
+
* Non-null from the input that ends an idle pause until the snapshot it
|
|
3166
|
+
* resumes on is in the buffer. What is recorded meanwhile - the touch
|
|
3167
|
+
* that woke the recorder, the screen it opened, a track() it caused - is
|
|
3168
|
+
* held here and appended behind that snapshot and the idle-resumed
|
|
3169
|
+
* marker, so the chunk after a pause always opens on its snapshot.
|
|
3170
|
+
*/
|
|
3171
|
+
__publicField(this, "idleResumeBacklog", null);
|
|
3172
|
+
/*
|
|
3173
|
+
* The soft keyboard is up. Keystrokes reach no view the recorder can see,
|
|
3174
|
+
* so someone writing a long message - a chat, a form, a review - would
|
|
3175
|
+
* otherwise look exactly like nobody touching the app. While it is up the
|
|
3176
|
+
* idle pause waits, and its showing and hiding are input
|
|
3177
|
+
* (onKeyboardChange). The 30-minute rollover does not look at it.
|
|
3178
|
+
*/
|
|
3179
|
+
__publicField(this, "softKeyboardVisible", false);
|
|
3180
|
+
__publicField(this, "keyboardSubscriptions", []);
|
|
3114
3181
|
this.storage = dependencies.storage ?? defaultReplayStorage;
|
|
3115
3182
|
this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
|
|
3116
3183
|
this.appState = dependencies.appState ?? AppState;
|
|
3184
|
+
this.keyboard = dependencies.keyboard ?? Keyboard ?? null;
|
|
3117
3185
|
this.now = dependencies.now ?? defaultNow;
|
|
3118
3186
|
}
|
|
3119
3187
|
start(options) {
|
|
@@ -3190,6 +3258,8 @@ var MobileReplayRecorder = class {
|
|
|
3190
3258
|
this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
|
|
3191
3259
|
this.touchQueue = Promise.resolve();
|
|
3192
3260
|
this.resetTouchSequences();
|
|
3261
|
+
this.idlePause = null;
|
|
3262
|
+
this.idleResumeBacklog = null;
|
|
3193
3263
|
const validated = validateStartOptions(options);
|
|
3194
3264
|
if (!validated) {
|
|
3195
3265
|
this.disable("invalid-start-options");
|
|
@@ -3269,6 +3339,7 @@ var MobileReplayRecorder = class {
|
|
|
3269
3339
|
);
|
|
3270
3340
|
}
|
|
3271
3341
|
this.sessionLastActivityUnixMs = nowUnixMs;
|
|
3342
|
+
this.lastInputUnixMs = nowUnixMs;
|
|
3272
3343
|
if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
|
|
3273
3344
|
this.disable("not-sampled");
|
|
3274
3345
|
return false;
|
|
@@ -3355,13 +3426,22 @@ var MobileReplayRecorder = class {
|
|
|
3355
3426
|
this.diagnosticsStatus = "stopped";
|
|
3356
3427
|
return;
|
|
3357
3428
|
}
|
|
3358
|
-
|
|
3429
|
+
const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
|
|
3430
|
+
const pause = this.idlePause;
|
|
3431
|
+
if (!endedIdle && pause === null) {
|
|
3359
3432
|
this.appendCustom(
|
|
3360
3433
|
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3361
3434
|
{ state: "stopped" },
|
|
3362
3435
|
void 0,
|
|
3363
3436
|
true
|
|
3364
3437
|
);
|
|
3438
|
+
} else if (!endedIdle && pause !== null && this.isPauseInStream(pause)) {
|
|
3439
|
+
this.idleResumeBacklog = null;
|
|
3440
|
+
this.appendMarker(
|
|
3441
|
+
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3442
|
+
{ state: "stopped" },
|
|
3443
|
+
pause.pausedAtUnixMs
|
|
3444
|
+
);
|
|
3365
3445
|
}
|
|
3366
3446
|
this.haltCapture();
|
|
3367
3447
|
await this.closeCurrent(true);
|
|
@@ -3510,7 +3590,7 @@ var MobileReplayRecorder = class {
|
|
|
3510
3590
|
}
|
|
3511
3591
|
async setRoute(route) {
|
|
3512
3592
|
await this.identityQueue;
|
|
3513
|
-
if (this.running && !this.acceptingEvents) {
|
|
3593
|
+
if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
|
|
3514
3594
|
this.route = sanitizeRoute(route);
|
|
3515
3595
|
return;
|
|
3516
3596
|
}
|
|
@@ -3546,7 +3626,15 @@ var MobileReplayRecorder = class {
|
|
|
3546
3626
|
if (!this.running || !this.acceptingEvents) {
|
|
3547
3627
|
return;
|
|
3548
3628
|
}
|
|
3629
|
+
const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
|
|
3549
3630
|
await this.maybeRotateForUser(this.now());
|
|
3631
|
+
if (resume === "resumed") {
|
|
3632
|
+
this.startTimers();
|
|
3633
|
+
await this.captureTick(true, false);
|
|
3634
|
+
if (!this.running || !this.acceptingEvents) {
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3550
3638
|
const safeReason2 = truncate(
|
|
3551
3639
|
(typeof reason === "string" ? reason.trim() : "") || "manual",
|
|
3552
3640
|
MAX_CAPTURE_REASON_LENGTH
|
|
@@ -3562,6 +3650,9 @@ var MobileReplayRecorder = class {
|
|
|
3562
3650
|
if (!this.running || !this.acceptingEvents) {
|
|
3563
3651
|
return;
|
|
3564
3652
|
}
|
|
3653
|
+
if (this.isIdlePaused()) {
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3565
3656
|
await this.maybeRotateSession(this.now());
|
|
3566
3657
|
if (this.sealedForIdle) {
|
|
3567
3658
|
return;
|
|
@@ -3587,9 +3678,18 @@ var MobileReplayRecorder = class {
|
|
|
3587
3678
|
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
3588
3679
|
return;
|
|
3589
3680
|
}
|
|
3681
|
+
const input = this.noteUserInput(this.now());
|
|
3682
|
+
let resumeCapture = null;
|
|
3683
|
+
if (input === "resumed") {
|
|
3684
|
+
this.startTimers();
|
|
3685
|
+
resumeCapture = this.captureTick(true);
|
|
3686
|
+
}
|
|
3590
3687
|
const generation = this.lifecycleGeneration;
|
|
3591
3688
|
const operation = this.touchQueue.then(
|
|
3592
3689
|
async () => {
|
|
3690
|
+
if (resumeCapture) {
|
|
3691
|
+
await resumeCapture;
|
|
3692
|
+
}
|
|
3593
3693
|
await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
|
|
3594
3694
|
}
|
|
3595
3695
|
);
|
|
@@ -3643,8 +3743,9 @@ var MobileReplayRecorder = class {
|
|
|
3643
3743
|
}
|
|
3644
3744
|
}
|
|
3645
3745
|
getDiagnostics() {
|
|
3746
|
+
const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
|
|
3646
3747
|
return {
|
|
3647
|
-
status
|
|
3748
|
+
status,
|
|
3648
3749
|
sessionId: this.identity?.sessionId ?? null,
|
|
3649
3750
|
recorderKind: MOBILE_RECORDER_KIND,
|
|
3650
3751
|
configEpoch: this.config?.configEpoch ?? 0,
|
|
@@ -3726,11 +3827,18 @@ var MobileReplayRecorder = class {
|
|
|
3726
3827
|
}
|
|
3727
3828
|
await this.transport?.restore();
|
|
3728
3829
|
}
|
|
3729
|
-
|
|
3830
|
+
/*
|
|
3831
|
+
* mayPauseForIdle is false only for the snapshot captureSession() takes
|
|
3832
|
+
* while paused, whose marker must be in before the pause check runs.
|
|
3833
|
+
*/
|
|
3834
|
+
async captureTick(force = false, mayPauseForIdle = true) {
|
|
3730
3835
|
const generation = this.lifecycleGeneration;
|
|
3731
3836
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
|
|
3732
3837
|
return;
|
|
3733
3838
|
}
|
|
3839
|
+
if (this.isIdlePaused()) {
|
|
3840
|
+
return;
|
|
3841
|
+
}
|
|
3734
3842
|
this.captureGeneration = generation;
|
|
3735
3843
|
try {
|
|
3736
3844
|
const nowUnixMs = this.now();
|
|
@@ -3746,6 +3854,9 @@ var MobileReplayRecorder = class {
|
|
|
3746
3854
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
|
|
3747
3855
|
return;
|
|
3748
3856
|
}
|
|
3857
|
+
if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3749
3860
|
const capturedSessionId = this.identity?.sessionId;
|
|
3750
3861
|
const tree = await this.nativeViewTree.captureViewTree(this.rootTag);
|
|
3751
3862
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.rotatingSession || !capturedSessionId || this.identity?.sessionId !== capturedSessionId) {
|
|
@@ -3763,6 +3874,9 @@ var MobileReplayRecorder = class {
|
|
|
3763
3874
|
if (this.chunkBuffer.shouldFlush()) {
|
|
3764
3875
|
await this.closeCurrent(false);
|
|
3765
3876
|
}
|
|
3877
|
+
if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
|
|
3878
|
+
await this.pauseForIdle(nowUnixMs, generation);
|
|
3879
|
+
}
|
|
3766
3880
|
} catch (error) {
|
|
3767
3881
|
if (this.running && this.isCurrentGeneration(generation)) {
|
|
3768
3882
|
this.disable("native-capture-failed", {
|
|
@@ -3794,6 +3908,163 @@ var MobileReplayRecorder = class {
|
|
|
3794
3908
|
if (capture.hasFullSnapshot) {
|
|
3795
3909
|
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
3796
3910
|
this.forceFullSnapshot = false;
|
|
3911
|
+
this.completeIdleResume(nowUnixMs);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
/*
|
|
3915
|
+
* The person did something: a touch, the soft keyboard showing or
|
|
3916
|
+
* hiding, or the app coming back to the foreground. It ends an idle
|
|
3917
|
+
* pause (see endIdlePause).
|
|
3918
|
+
*/
|
|
3919
|
+
noteUserInput(nowUnixMs) {
|
|
3920
|
+
this.lastInputUnixMs = Math.max(this.lastInputUnixMs, nowUnixMs);
|
|
3921
|
+
return this.endIdlePause(nowUnixMs);
|
|
3922
|
+
}
|
|
3923
|
+
/*
|
|
3924
|
+
* What someone being back does to an idle pause, on its own for
|
|
3925
|
+
* captureSession(), which acts on the pause without being input.
|
|
3926
|
+
*
|
|
3927
|
+
* Inside the session's idle window the same session resumes, on the
|
|
3928
|
+
* snapshot of the next capture; the caller starts that capture. Once
|
|
3929
|
+
* the window has run out - or the session already ended idle - the
|
|
3930
|
+
* session is over, and the caller starts the next one through
|
|
3931
|
+
* maybeRotateForUser, as on any idle session; nothing changes here.
|
|
3932
|
+
*/
|
|
3933
|
+
endIdlePause(nowUnixMs) {
|
|
3934
|
+
if (this.sealedForIdle) {
|
|
3935
|
+
return "session-ended";
|
|
3936
|
+
}
|
|
3937
|
+
if (!this.isIdlePaused()) {
|
|
3938
|
+
return "none";
|
|
3939
|
+
}
|
|
3940
|
+
if (this.rotationReason(nowUnixMs) !== null) {
|
|
3941
|
+
return "session-ended";
|
|
3942
|
+
}
|
|
3943
|
+
this.idleResumeBacklog = [];
|
|
3944
|
+
this.forceFullSnapshot = true;
|
|
3945
|
+
this.sessionLastActivityUnixMs = Math.max(
|
|
3946
|
+
this.sessionLastActivityUnixMs,
|
|
3947
|
+
nowUnixMs
|
|
3948
|
+
);
|
|
3949
|
+
return "resumed";
|
|
3950
|
+
}
|
|
3951
|
+
/* Paused for idle with nobody back yet: nothing is captured or recorded. */
|
|
3952
|
+
isIdlePaused() {
|
|
3953
|
+
return this.idlePause !== null && this.idleResumeBacklog === null;
|
|
3954
|
+
}
|
|
3955
|
+
/*
|
|
3956
|
+
* Whether the pause's marker is in the stream being recorded now, which
|
|
3957
|
+
* is what owes it the idle-resumed marker and dates a stop at it.
|
|
3958
|
+
*/
|
|
3959
|
+
isPauseInStream(pause) {
|
|
3960
|
+
return pause.marked && this.identity?.sessionId === pause.sessionId && this.identity?.tabId === pause.tabId;
|
|
3961
|
+
}
|
|
3962
|
+
/*
|
|
3963
|
+
* Stop capturing: nobody has touched the app for the pause window.
|
|
3964
|
+
*
|
|
3965
|
+
* In this order: the idle-paused marker goes in as the last event of the
|
|
3966
|
+
* open chunk, that chunk goes out through the ordinary path, and the
|
|
3967
|
+
* sampler stops. So the last thing the recording holds before the
|
|
3968
|
+
* stretch nobody recorded says so - and a session the person never comes
|
|
3969
|
+
* back to has already ended there when its idle window runs out, so the
|
|
3970
|
+
* idle seal (sealForIdle) has nothing left to send.
|
|
3971
|
+
*
|
|
3972
|
+
* A recorder holding its pre-roll in memory (error-triggered, or waiting
|
|
3973
|
+
* for consent) has nothing to mark and nothing to send. Its pre-roll is
|
|
3974
|
+
* dropped instead: a trigger once the person is back must not upload a
|
|
3975
|
+
* minute of nobody in front of a snapshot taken after an unrecorded gap.
|
|
3976
|
+
*/
|
|
3977
|
+
async pauseForIdle(nowUnixMs, generation) {
|
|
3978
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.idlePause !== null || this.sealedForIdle || !this.identity) {
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
const paused = {
|
|
3982
|
+
/*
|
|
3983
|
+
* Never before the session began: one that started with nobody there
|
|
3984
|
+
* (the chunk cap rolled it over) was not idle before it existed.
|
|
3985
|
+
*/
|
|
3986
|
+
idleSinceUnixMs: Math.min(
|
|
3987
|
+
nowUnixMs,
|
|
3988
|
+
Math.max(this.lastInputUnixMs, this.identity.sessionStartUnixMs)
|
|
3989
|
+
),
|
|
3990
|
+
pausedAtUnixMs: nowUnixMs
|
|
3991
|
+
};
|
|
3992
|
+
const marked = this.uploadActive;
|
|
3993
|
+
this.idlePause = {
|
|
3994
|
+
sessionId: this.identity.sessionId,
|
|
3995
|
+
tabId: this.identity.tabId,
|
|
3996
|
+
idleSinceUnixMs: paused.idleSinceUnixMs,
|
|
3997
|
+
pausedAtUnixMs: paused.pausedAtUnixMs,
|
|
3998
|
+
marked
|
|
3999
|
+
};
|
|
4000
|
+
this.stopCaptureTimer();
|
|
4001
|
+
this.diagnostic("idle-paused", {
|
|
4002
|
+
idleForMs: nowUnixMs - paused.idleSinceUnixMs
|
|
4003
|
+
});
|
|
4004
|
+
if (!marked) {
|
|
4005
|
+
this.chunkBuffer.clear();
|
|
4006
|
+
this.rollingBuffer.clear();
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
this.appendMarker(IDLE_PAUSED_CUSTOM_EVENT_TAG, paused, nowUnixMs);
|
|
4010
|
+
await this.closeCurrent(false);
|
|
4011
|
+
}
|
|
4012
|
+
/*
|
|
4013
|
+
* The snapshot the session resumes on is in the buffer: the idle-resumed
|
|
4014
|
+
* marker goes directly behind it, then whatever was recorded since the
|
|
4015
|
+
* input that woke the recorder. Same session, same tab and - the pause
|
|
4016
|
+
* having closed its chunk - the next chunk index.
|
|
4017
|
+
*/
|
|
4018
|
+
completeIdleResume(nowUnixMs) {
|
|
4019
|
+
const pause = this.idlePause;
|
|
4020
|
+
const held = this.idleResumeBacklog;
|
|
4021
|
+
if (pause === null || held === null) {
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
this.idlePause = null;
|
|
4025
|
+
this.idleResumeBacklog = null;
|
|
4026
|
+
const resumedAtUnixMs = Math.max(pause.pausedAtUnixMs, nowUnixMs);
|
|
4027
|
+
if (this.isPauseInStream(pause)) {
|
|
4028
|
+
const resumed = {
|
|
4029
|
+
pausedAtUnixMs: pause.pausedAtUnixMs,
|
|
4030
|
+
resumedAtUnixMs
|
|
4031
|
+
};
|
|
4032
|
+
this.appendMarker(
|
|
4033
|
+
IDLE_RESUMED_CUSTOM_EVENT_TAG,
|
|
4034
|
+
resumed,
|
|
4035
|
+
resumedAtUnixMs
|
|
4036
|
+
);
|
|
4037
|
+
}
|
|
4038
|
+
this.releaseHeldEvents(held);
|
|
4039
|
+
this.diagnostic("idle-resumed", {
|
|
4040
|
+
pausedForMs: resumedAtUnixMs - pause.pausedAtUnixMs
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
/*
|
|
4044
|
+
* A marker the recorder writes about itself - the idle pause and resume,
|
|
4045
|
+
* and a stop while paused - straight into the open chunk, stamped with
|
|
4046
|
+
* the moment it stands for. None of them is activity (the idle rollover
|
|
4047
|
+
* must not move for them), and none is "now": a stop while paused is
|
|
4048
|
+
* dated at the pause.
|
|
4049
|
+
*/
|
|
4050
|
+
appendMarker(tag, payload, timestamp) {
|
|
4051
|
+
const options = {};
|
|
4052
|
+
if (this.options) {
|
|
4053
|
+
options.route = toAppUrl(this.options.mobileAppIdentifier, this.route);
|
|
4054
|
+
}
|
|
4055
|
+
if (!this.chunkBuffer.append(
|
|
4056
|
+
createCustomEvent(tag, payload, timestamp),
|
|
4057
|
+
options
|
|
4058
|
+
)) {
|
|
4059
|
+
this.droppedEvents += 1;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
/* What was held for a resume, in the order it was recorded. */
|
|
4063
|
+
releaseHeldEvents(held) {
|
|
4064
|
+
for (const entry of held) {
|
|
4065
|
+
if (!this.chunkBuffer.append(entry.event, entry.options)) {
|
|
4066
|
+
this.droppedEvents += 1;
|
|
4067
|
+
}
|
|
3797
4068
|
}
|
|
3798
4069
|
}
|
|
3799
4070
|
appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
|
|
@@ -3815,10 +4086,9 @@ var MobileReplayRecorder = class {
|
|
|
3815
4086
|
this.diagnostic("invalid-event-timestamp");
|
|
3816
4087
|
return;
|
|
3817
4088
|
}
|
|
3818
|
-
this.
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
);
|
|
4089
|
+
if (this.isIdlePaused()) {
|
|
4090
|
+
return;
|
|
4091
|
+
}
|
|
3822
4092
|
const appendOptions = {};
|
|
3823
4093
|
if (this.options) {
|
|
3824
4094
|
appendOptions.route = toAppUrl(
|
|
@@ -3829,6 +4099,18 @@ var MobileReplayRecorder = class {
|
|
|
3829
4099
|
if (signal) {
|
|
3830
4100
|
appendOptions.signal = signal;
|
|
3831
4101
|
}
|
|
4102
|
+
if (this.idleResumeBacklog !== null) {
|
|
4103
|
+
if (this.idleResumeBacklog.length < MAX_EVENTS_HELD_FOR_RESUME) {
|
|
4104
|
+
this.idleResumeBacklog.push({ event, options: appendOptions });
|
|
4105
|
+
} else {
|
|
4106
|
+
this.droppedEvents += 1;
|
|
4107
|
+
}
|
|
4108
|
+
return;
|
|
4109
|
+
}
|
|
4110
|
+
this.sessionLastActivityUnixMs = Math.max(
|
|
4111
|
+
this.sessionLastActivityUnixMs,
|
|
4112
|
+
event.timestamp
|
|
4113
|
+
);
|
|
3832
4114
|
const accepted = this.chunkBuffer.append(event, appendOptions);
|
|
3833
4115
|
if (!accepted) {
|
|
3834
4116
|
this.droppedEvents += 1;
|
|
@@ -4014,6 +4296,63 @@ var MobileReplayRecorder = class {
|
|
|
4014
4296
|
});
|
|
4015
4297
|
}
|
|
4016
4298
|
);
|
|
4299
|
+
this.installKeyboardListeners();
|
|
4300
|
+
}
|
|
4301
|
+
installKeyboardListeners() {
|
|
4302
|
+
this.removeKeyboardListeners();
|
|
4303
|
+
const keyboard = this.keyboard;
|
|
4304
|
+
this.softKeyboardVisible = false;
|
|
4305
|
+
if (!keyboard || typeof keyboard.addListener !== "function") {
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
try {
|
|
4309
|
+
this.softKeyboardVisible = keyboard.isVisible?.() === true;
|
|
4310
|
+
} catch {
|
|
4311
|
+
this.softKeyboardVisible = false;
|
|
4312
|
+
}
|
|
4313
|
+
try {
|
|
4314
|
+
this.keyboardSubscriptions.push(
|
|
4315
|
+
keyboard.addListener("keyboardDidShow", () => {
|
|
4316
|
+
this.onKeyboardChange(true);
|
|
4317
|
+
}),
|
|
4318
|
+
keyboard.addListener("keyboardDidHide", () => {
|
|
4319
|
+
this.onKeyboardChange(false);
|
|
4320
|
+
})
|
|
4321
|
+
);
|
|
4322
|
+
} catch {
|
|
4323
|
+
this.removeKeyboardListeners();
|
|
4324
|
+
this.softKeyboardVisible = false;
|
|
4325
|
+
this.diagnostic("keyboard-subscribe-failed");
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
removeKeyboardListeners() {
|
|
4329
|
+
const subscriptions = this.keyboardSubscriptions;
|
|
4330
|
+
this.keyboardSubscriptions = [];
|
|
4331
|
+
for (const subscription of subscriptions) {
|
|
4332
|
+
try {
|
|
4333
|
+
subscription.remove();
|
|
4334
|
+
} catch {
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
/*
|
|
4339
|
+
* The soft keyboard came up or went away. That is the person - a field
|
|
4340
|
+
* tapped, a message sent - so it counts as input and resumes a paused
|
|
4341
|
+
* recording the way a touch does; and while it is up the idle pause
|
|
4342
|
+
* waits, since what is typed on it reaches no view the recorder sees.
|
|
4343
|
+
*/
|
|
4344
|
+
onKeyboardChange(visible) {
|
|
4345
|
+
this.softKeyboardVisible = visible;
|
|
4346
|
+
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
const input = this.noteUserInput(this.now());
|
|
4350
|
+
if (input === "resumed") {
|
|
4351
|
+
this.startTimers();
|
|
4352
|
+
void this.captureTick(true);
|
|
4353
|
+
} else if (input === "session-ended") {
|
|
4354
|
+
void this.maybeRotateForUser(this.now());
|
|
4355
|
+
}
|
|
4017
4356
|
}
|
|
4018
4357
|
async onAppStateChange(state, generation) {
|
|
4019
4358
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
@@ -4033,6 +4372,13 @@ var MobileReplayRecorder = class {
|
|
|
4033
4372
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4034
4373
|
return;
|
|
4035
4374
|
}
|
|
4375
|
+
if (this.idlePause !== null || this.sealedForIdle) {
|
|
4376
|
+
this.idleResumeBacklog = null;
|
|
4377
|
+
if (this.uploadActive) {
|
|
4378
|
+
await this.transport?.drain();
|
|
4379
|
+
}
|
|
4380
|
+
return;
|
|
4381
|
+
}
|
|
4036
4382
|
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
|
|
4037
4383
|
await this.closeCurrent(false);
|
|
4038
4384
|
if (this.uploadActive) {
|
|
@@ -4042,6 +4388,7 @@ var MobileReplayRecorder = class {
|
|
|
4042
4388
|
}
|
|
4043
4389
|
if (isActive && !this.foreground) {
|
|
4044
4390
|
this.foreground = true;
|
|
4391
|
+
const input = this.noteUserInput(this.now());
|
|
4045
4392
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4046
4393
|
generation,
|
|
4047
4394
|
true,
|
|
@@ -4055,7 +4402,10 @@ var MobileReplayRecorder = class {
|
|
|
4055
4402
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4056
4403
|
return;
|
|
4057
4404
|
}
|
|
4058
|
-
|
|
4405
|
+
const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
|
|
4406
|
+
if (!resumedPausedSession) {
|
|
4407
|
+
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
|
|
4408
|
+
}
|
|
4059
4409
|
const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
|
|
4060
4410
|
if (!openedOnSnapshot) {
|
|
4061
4411
|
this.forceFullSnapshot = true;
|
|
@@ -4081,16 +4431,31 @@ var MobileReplayRecorder = class {
|
|
|
4081
4431
|
void this.captureTick();
|
|
4082
4432
|
}, intervalMs);
|
|
4083
4433
|
this.flushTimer = setInterval(() => {
|
|
4434
|
+
if (this.isIdlePaused()) {
|
|
4435
|
+
if (this.acceptingEvents) {
|
|
4436
|
+
void this.maybeRotateSession(this.now());
|
|
4437
|
+
}
|
|
4438
|
+
return;
|
|
4439
|
+
}
|
|
4084
4440
|
if (this.acceptingEvents) {
|
|
4085
4441
|
void this.closeCurrent(false);
|
|
4086
4442
|
}
|
|
4087
4443
|
}, SESSION_REPLAY_FLUSH_INTERVAL_MS);
|
|
4088
4444
|
}
|
|
4089
|
-
|
|
4445
|
+
/*
|
|
4446
|
+
* The sampler alone. An idle pause stops it - every tick is a native
|
|
4447
|
+
* traversal of the whole view tree, which is what a pause is for not
|
|
4448
|
+
* paying - and keeps the 15-second flush timer as its heartbeat, rather
|
|
4449
|
+
* than waking the JS thread twice a second to find nothing to do.
|
|
4450
|
+
*/
|
|
4451
|
+
stopCaptureTimer() {
|
|
4090
4452
|
if (this.captureTimer) {
|
|
4091
4453
|
clearInterval(this.captureTimer);
|
|
4092
4454
|
this.captureTimer = null;
|
|
4093
4455
|
}
|
|
4456
|
+
}
|
|
4457
|
+
clearTimers() {
|
|
4458
|
+
this.stopCaptureTimer();
|
|
4094
4459
|
if (this.flushTimer) {
|
|
4095
4460
|
clearInterval(this.flushTimer);
|
|
4096
4461
|
this.flushTimer = null;
|
|
@@ -4182,6 +4547,7 @@ var MobileReplayRecorder = class {
|
|
|
4182
4547
|
this.clearTimers();
|
|
4183
4548
|
this.appStateSubscription?.remove();
|
|
4184
4549
|
this.appStateSubscription = null;
|
|
4550
|
+
this.removeKeyboardListeners();
|
|
4185
4551
|
this.stopConnectivity();
|
|
4186
4552
|
this.restoreGlobalErrorHandler();
|
|
4187
4553
|
}
|
|
@@ -4342,8 +4708,12 @@ var MobileReplayRecorder = class {
|
|
|
4342
4708
|
if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
|
|
4343
4709
|
return;
|
|
4344
4710
|
}
|
|
4345
|
-
if (!this.rotationPromise && !this.sealedForIdle
|
|
4346
|
-
|
|
4711
|
+
if (!this.rotationPromise && !this.sealedForIdle) {
|
|
4712
|
+
if (this.isIdlePaused()) {
|
|
4713
|
+
await this.sealForIdleNow();
|
|
4714
|
+
} else if (this.rotationReason(this.now()) === "idle") {
|
|
4715
|
+
await this.maybeRotateSession(this.now());
|
|
4716
|
+
}
|
|
4347
4717
|
}
|
|
4348
4718
|
if (this.rotationPromise) {
|
|
4349
4719
|
await this.rotationPromise;
|
|
@@ -4759,6 +5129,9 @@ var MobileReplayRecorder = class {
|
|
|
4759
5129
|
reason = "idle";
|
|
4760
5130
|
} else {
|
|
4761
5131
|
reason = this.rotationReason(nowUnixMs);
|
|
5132
|
+
if (reason !== null && this.isIdlePaused()) {
|
|
5133
|
+
reason = "idle";
|
|
5134
|
+
}
|
|
4762
5135
|
}
|
|
4763
5136
|
if (!reason) {
|
|
4764
5137
|
return;
|
|
@@ -4775,14 +5148,36 @@ var MobileReplayRecorder = class {
|
|
|
4775
5148
|
this.rotatingSession = false;
|
|
4776
5149
|
}
|
|
4777
5150
|
}
|
|
5151
|
+
/*
|
|
5152
|
+
* The idle seal on demand, under the lock every rotation takes: for an
|
|
5153
|
+
* identity change on a session paused for idle, which no clock has ended
|
|
5154
|
+
* yet but nobody is in.
|
|
5155
|
+
*/
|
|
5156
|
+
async sealForIdleNow() {
|
|
5157
|
+
if (this.rotationPromise || this.rotatingSession || this.sealedForIdle) {
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
this.rotatingSession = true;
|
|
5161
|
+
const operation = this.sealForIdle();
|
|
5162
|
+
this.rotationPromise = operation;
|
|
5163
|
+
try {
|
|
5164
|
+
await operation;
|
|
5165
|
+
} finally {
|
|
5166
|
+
if (this.rotationPromise === operation) {
|
|
5167
|
+
this.rotationPromise = null;
|
|
5168
|
+
}
|
|
5169
|
+
this.rotatingSession = false;
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
4778
5172
|
/*
|
|
4779
5173
|
* maybeRotateSession for a user who is here. On an app whose session
|
|
4780
|
-
* ended idle
|
|
5174
|
+
* ended idle - or was paused for idle, which stops the capture tick too
|
|
5175
|
+
* (#4208) - the policy is brought up to date first: no capture tick has
|
|
4781
5176
|
* refreshed it while the app sat idle, and the next session must not open
|
|
4782
5177
|
* under one that has since changed (or turned replay off).
|
|
4783
5178
|
*/
|
|
4784
5179
|
async maybeRotateForUser(nowUnixMs) {
|
|
4785
|
-
if (this.sealedForIdle && !this.rotatingSession) {
|
|
5180
|
+
if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
|
|
4786
5181
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4787
5182
|
this.lifecycleGeneration,
|
|
4788
5183
|
false,
|
|
@@ -4801,10 +5196,14 @@ var MobileReplayRecorder = class {
|
|
|
4801
5196
|
* never by now - and with no marker of its own: a marker stamped now
|
|
4802
5197
|
* would date the session's end half an hour or more after anything the
|
|
4803
5198
|
* player can show. With nothing buffered nothing is sent, and the server
|
|
4804
|
-
* ends the session at its last chunk
|
|
5199
|
+
* ends the session at its last chunk - for a session paused for idle
|
|
5200
|
+
* (#4208), the chunk its idle-paused marker closed.
|
|
4805
5201
|
*/
|
|
4806
5202
|
async sealForIdle() {
|
|
4807
5203
|
this.sealedForIdle = true;
|
|
5204
|
+
this.idlePause = null;
|
|
5205
|
+
this.idleResumeBacklog = null;
|
|
5206
|
+
this.clearTimers();
|
|
4808
5207
|
this.diagnostic("session-ended-idle", {
|
|
4809
5208
|
idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
|
|
4810
5209
|
});
|
|
@@ -4819,6 +5218,9 @@ var MobileReplayRecorder = class {
|
|
|
4819
5218
|
} else {
|
|
4820
5219
|
this.rollingBuffer.clear();
|
|
4821
5220
|
}
|
|
5221
|
+
if (this.running && this.identityPersisted) {
|
|
5222
|
+
await this.sessionStore?.end();
|
|
5223
|
+
}
|
|
4822
5224
|
}
|
|
4823
5225
|
async performSessionRotation(nowUnixMs, reason) {
|
|
4824
5226
|
if (reason !== "idle") {
|
|
@@ -4901,6 +5303,8 @@ var MobileReplayRecorder = class {
|
|
|
4901
5303
|
}
|
|
4902
5304
|
this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
|
|
4903
5305
|
this.sealedForIdle = false;
|
|
5306
|
+
this.idlePause = null;
|
|
5307
|
+
this.idleResumeBacklog = null;
|
|
4904
5308
|
this.notifySessionChange();
|
|
4905
5309
|
this.sampled = this.identityPersisted ? deterministicSample(
|
|
4906
5310
|
this.identity.sessionId,
|
|
@@ -4921,6 +5325,10 @@ var MobileReplayRecorder = class {
|
|
|
4921
5325
|
if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
|
|
4922
5326
|
this.disable("not-sampled");
|
|
4923
5327
|
this.haltCapture();
|
|
5328
|
+
return;
|
|
5329
|
+
}
|
|
5330
|
+
if (this.captureTimer === null && this.running && this.foreground) {
|
|
5331
|
+
this.startTimers();
|
|
4924
5332
|
}
|
|
4925
5333
|
}
|
|
4926
5334
|
disable(code, details) {
|