@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.cjs
CHANGED
|
@@ -90,6 +90,7 @@ var SESSION_REPLAY_CHECKOUT_INTERVAL_MS = 6e4;
|
|
|
90
90
|
var SESSION_REPLAY_ROLLING_BUFFER_MS = 6e4;
|
|
91
91
|
var SESSION_REPLAY_ROLLING_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
92
92
|
var SESSION_REPLAY_IDLE_ROLLOVER_MS = 30 * 6e4;
|
|
93
|
+
var SESSION_REPLAY_IDLE_PAUSE_MS = 5 * 6e4;
|
|
93
94
|
var SESSION_REPLAY_MAX_SESSION_MS = 4 * 60 * 6e4;
|
|
94
95
|
var SESSION_REPLAY_MAX_DECOMPRESSED_FRAME_BYTES = 8 * 1024 * 1024;
|
|
95
96
|
var MAX_SESSION_REPLAY_CHUNK_BYTES = 2 * 1024 * 1024;
|
|
@@ -98,7 +99,7 @@ var SESSION_REPLAY_MAX_OFFLINE_DELAY_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
|
98
99
|
var MOBILE_CAPTURE_INTERVAL_MS = 500;
|
|
99
100
|
var MOBILE_RECORDER_KIND = "rn-view-tree";
|
|
100
101
|
var SYNTHETIC_RRWEB_VERSION = "synthetic-1";
|
|
101
|
-
var RECORDER_VERSION = true ? "14.0.
|
|
102
|
+
var RECORDER_VERSION = true ? "14.0.13" : "0.0.0-dev";
|
|
102
103
|
var SESSION_REPLAY_MOBILE_RECORDER_CAPABILITIES = [
|
|
103
104
|
"mobile-view-tree",
|
|
104
105
|
"mobile-touch-events",
|
|
@@ -151,6 +152,8 @@ var TAGS_CUSTOM_EVENT_TAG = "oneuptime.tags";
|
|
|
151
152
|
var VISIBILITY_CUSTOM_EVENT_TAG = "oneuptime.visibility";
|
|
152
153
|
var TOUCH_CUSTOM_EVENT_TAG = "oneuptime.touch";
|
|
153
154
|
var SESSION_ROTATED_CUSTOM_EVENT_TAG = "oneuptime.session-rotated";
|
|
155
|
+
var IDLE_PAUSED_CUSTOM_EVENT_TAG = "oneuptime.idle-paused";
|
|
156
|
+
var IDLE_RESUMED_CUSTOM_EVENT_TAG = "oneuptime.idle-resumed";
|
|
154
157
|
|
|
155
158
|
// src/Sanitize.ts
|
|
156
159
|
var MAX_TRAIT_KEYS = 20;
|
|
@@ -2526,6 +2529,18 @@ var ReplaySessionStore = class {
|
|
|
2526
2529
|
};
|
|
2527
2530
|
return { ...this.identity };
|
|
2528
2531
|
}
|
|
2532
|
+
/*
|
|
2533
|
+
* Forget the stored session without starting the next one. A session
|
|
2534
|
+
* that ends while the recorder is paused for idle has no successor until
|
|
2535
|
+
* the person comes back, and its record - last written by its own final
|
|
2536
|
+
* chunk, so it looks recent - would otherwise let a relaunch in the
|
|
2537
|
+
* meantime carry on a session that is already over. The visitor id stays,
|
|
2538
|
+
* and so does the in-memory identity: nothing more is sent under it, and
|
|
2539
|
+
* the next rotate() writes the new session.
|
|
2540
|
+
*/
|
|
2541
|
+
async end() {
|
|
2542
|
+
await this.safeRemove(this.sessionKey);
|
|
2543
|
+
}
|
|
2529
2544
|
async clear() {
|
|
2530
2545
|
this.identity = null;
|
|
2531
2546
|
await Promise.all([
|
|
@@ -2984,6 +2999,7 @@ function isTouchPrivate(privacyMap, x2, y) {
|
|
|
2984
2999
|
var MAX_DIAGNOSTIC_EVENTS = 100;
|
|
2985
3000
|
var TOUCH_MOVE_SAMPLE_MS = 50;
|
|
2986
3001
|
var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
|
|
3002
|
+
var MAX_EVENTS_HELD_FOR_RESUME = 500;
|
|
2987
3003
|
var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
2988
3004
|
"AggregateError",
|
|
2989
3005
|
"Error",
|
|
@@ -3055,6 +3071,7 @@ var MobileReplayRecorder = class {
|
|
|
3055
3071
|
__publicField(this, "storage");
|
|
3056
3072
|
__publicField(this, "nativeViewTree");
|
|
3057
3073
|
__publicField(this, "appState");
|
|
3074
|
+
__publicField(this, "keyboard");
|
|
3058
3075
|
__publicField(this, "now");
|
|
3059
3076
|
__publicField(this, "serializer", new ViewTreeSerializer());
|
|
3060
3077
|
__publicField(this, "chunkBuffer", new ReplayChunkBuffer());
|
|
@@ -3130,8 +3147,10 @@ var MobileReplayRecorder = class {
|
|
|
3130
3147
|
* re-snapshots every minute, so it filed a new session every half hour
|
|
3131
3148
|
* for as long as it stayed open. Now nothing is captured, uploaded or
|
|
3132
3149
|
* handed to the host as a session id until the user is back - a touch,
|
|
3133
|
-
* the app returning to the foreground, or an explicit
|
|
3134
|
-
* and that starts the next session (see
|
|
3150
|
+
* the soft keyboard, the app returning to the foreground, or an explicit
|
|
3151
|
+
* captureSession() - and that starts the next session (see
|
|
3152
|
+
* maybeRotateSession). In the foreground the session was almost always
|
|
3153
|
+
* paused for idle first (idlePause), so its footage already ended there.
|
|
3135
3154
|
*/
|
|
3136
3155
|
__publicField(this, "sealedForIdle", false);
|
|
3137
3156
|
__publicField(this, "rotatingSession", false);
|
|
@@ -3150,9 +3169,58 @@ var MobileReplayRecorder = class {
|
|
|
3150
3169
|
__publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
|
|
3151
3170
|
__publicField(this, "unidentifiedTouchCount", 0);
|
|
3152
3171
|
__publicField(this, "unidentifiedTouchPrivate", false);
|
|
3172
|
+
/*
|
|
3173
|
+
* When the person last did something: a touch (any touch - one inside a
|
|
3174
|
+
* ReplayMask is still someone using the app, even though it is never
|
|
3175
|
+
* recorded), the app coming back to the foreground, or the app starting.
|
|
3176
|
+
* Not sessionLastActivityUnixMs, which every recorded event moves: a
|
|
3177
|
+
* route change, a track() call or a JS error the app raises by itself is
|
|
3178
|
+
* the app, not the person, and a polling screen would otherwise never
|
|
3179
|
+
* look idle.
|
|
3180
|
+
*/
|
|
3181
|
+
__publicField(this, "lastInputUnixMs", 0);
|
|
3182
|
+
/*
|
|
3183
|
+
* The idle pause (SESSION_REPLAY_IDLE_PAUSE_MS, #4208). Set when the
|
|
3184
|
+
* capture tick finds nobody has touched the app for that long: the open
|
|
3185
|
+
* chunk closes with an idle-paused marker as its last event, the capture
|
|
3186
|
+
* timer stops, and from then on nothing is recorded - no view-tree
|
|
3187
|
+
* sample, no checkout, no chunk, and every event the app emits is
|
|
3188
|
+
* dropped rather than queued. The 15-second flush timer is the only one
|
|
3189
|
+
* left, and all it does is end the session (sealForIdle, #4207) if nobody
|
|
3190
|
+
* comes back inside SESSION_REPLAY_IDLE_ROLLOVER_MS.
|
|
3191
|
+
*
|
|
3192
|
+
* It exists because the recorder used to go on sampling the view tree
|
|
3193
|
+
* every 500 ms, with a full checkout every minute, for the whole half
|
|
3194
|
+
* hour before the idle rollover - the phone's CPU and battery and the
|
|
3195
|
+
* customer's upload and storage, all spent on footage of nobody.
|
|
3196
|
+
*
|
|
3197
|
+
* Input inside that window - a touch, the soft keyboard, the app
|
|
3198
|
+
* returning to the foreground - resumes the same session and tab on a
|
|
3199
|
+
* fresh full snapshot with an idle-resumed marker directly behind it
|
|
3200
|
+
* (idleResumeBacklog), at the next chunk index.
|
|
3201
|
+
*/
|
|
3202
|
+
__publicField(this, "idlePause", null);
|
|
3203
|
+
/*
|
|
3204
|
+
* Non-null from the input that ends an idle pause until the snapshot it
|
|
3205
|
+
* resumes on is in the buffer. What is recorded meanwhile - the touch
|
|
3206
|
+
* that woke the recorder, the screen it opened, a track() it caused - is
|
|
3207
|
+
* held here and appended behind that snapshot and the idle-resumed
|
|
3208
|
+
* marker, so the chunk after a pause always opens on its snapshot.
|
|
3209
|
+
*/
|
|
3210
|
+
__publicField(this, "idleResumeBacklog", null);
|
|
3211
|
+
/*
|
|
3212
|
+
* The soft keyboard is up. Keystrokes reach no view the recorder can see,
|
|
3213
|
+
* so someone writing a long message - a chat, a form, a review - would
|
|
3214
|
+
* otherwise look exactly like nobody touching the app. While it is up the
|
|
3215
|
+
* idle pause waits, and its showing and hiding are input
|
|
3216
|
+
* (onKeyboardChange). The 30-minute rollover does not look at it.
|
|
3217
|
+
*/
|
|
3218
|
+
__publicField(this, "softKeyboardVisible", false);
|
|
3219
|
+
__publicField(this, "keyboardSubscriptions", []);
|
|
3153
3220
|
this.storage = dependencies.storage ?? defaultReplayStorage;
|
|
3154
3221
|
this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
|
|
3155
3222
|
this.appState = dependencies.appState ?? import_react_native2.AppState;
|
|
3223
|
+
this.keyboard = dependencies.keyboard ?? import_react_native2.Keyboard ?? null;
|
|
3156
3224
|
this.now = dependencies.now ?? defaultNow;
|
|
3157
3225
|
}
|
|
3158
3226
|
start(options) {
|
|
@@ -3229,6 +3297,8 @@ var MobileReplayRecorder = class {
|
|
|
3229
3297
|
this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
|
|
3230
3298
|
this.touchQueue = Promise.resolve();
|
|
3231
3299
|
this.resetTouchSequences();
|
|
3300
|
+
this.idlePause = null;
|
|
3301
|
+
this.idleResumeBacklog = null;
|
|
3232
3302
|
const validated = validateStartOptions(options);
|
|
3233
3303
|
if (!validated) {
|
|
3234
3304
|
this.disable("invalid-start-options");
|
|
@@ -3308,6 +3378,7 @@ var MobileReplayRecorder = class {
|
|
|
3308
3378
|
);
|
|
3309
3379
|
}
|
|
3310
3380
|
this.sessionLastActivityUnixMs = nowUnixMs;
|
|
3381
|
+
this.lastInputUnixMs = nowUnixMs;
|
|
3311
3382
|
if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
|
|
3312
3383
|
this.disable("not-sampled");
|
|
3313
3384
|
return false;
|
|
@@ -3394,13 +3465,22 @@ var MobileReplayRecorder = class {
|
|
|
3394
3465
|
this.diagnosticsStatus = "stopped";
|
|
3395
3466
|
return;
|
|
3396
3467
|
}
|
|
3397
|
-
|
|
3468
|
+
const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
|
|
3469
|
+
const pause = this.idlePause;
|
|
3470
|
+
if (!endedIdle && pause === null) {
|
|
3398
3471
|
this.appendCustom(
|
|
3399
3472
|
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3400
3473
|
{ state: "stopped" },
|
|
3401
3474
|
void 0,
|
|
3402
3475
|
true
|
|
3403
3476
|
);
|
|
3477
|
+
} else if (!endedIdle && pause !== null && this.isPauseInStream(pause)) {
|
|
3478
|
+
this.idleResumeBacklog = null;
|
|
3479
|
+
this.appendMarker(
|
|
3480
|
+
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3481
|
+
{ state: "stopped" },
|
|
3482
|
+
pause.pausedAtUnixMs
|
|
3483
|
+
);
|
|
3404
3484
|
}
|
|
3405
3485
|
this.haltCapture();
|
|
3406
3486
|
await this.closeCurrent(true);
|
|
@@ -3549,7 +3629,7 @@ var MobileReplayRecorder = class {
|
|
|
3549
3629
|
}
|
|
3550
3630
|
async setRoute(route) {
|
|
3551
3631
|
await this.identityQueue;
|
|
3552
|
-
if (this.running && !this.acceptingEvents) {
|
|
3632
|
+
if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
|
|
3553
3633
|
this.route = sanitizeRoute(route);
|
|
3554
3634
|
return;
|
|
3555
3635
|
}
|
|
@@ -3585,7 +3665,15 @@ var MobileReplayRecorder = class {
|
|
|
3585
3665
|
if (!this.running || !this.acceptingEvents) {
|
|
3586
3666
|
return;
|
|
3587
3667
|
}
|
|
3668
|
+
const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
|
|
3588
3669
|
await this.maybeRotateForUser(this.now());
|
|
3670
|
+
if (resume === "resumed") {
|
|
3671
|
+
this.startTimers();
|
|
3672
|
+
await this.captureTick(true, false);
|
|
3673
|
+
if (!this.running || !this.acceptingEvents) {
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3589
3677
|
const safeReason2 = truncate(
|
|
3590
3678
|
(typeof reason === "string" ? reason.trim() : "") || "manual",
|
|
3591
3679
|
MAX_CAPTURE_REASON_LENGTH
|
|
@@ -3601,6 +3689,9 @@ var MobileReplayRecorder = class {
|
|
|
3601
3689
|
if (!this.running || !this.acceptingEvents) {
|
|
3602
3690
|
return;
|
|
3603
3691
|
}
|
|
3692
|
+
if (this.isIdlePaused()) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3604
3695
|
await this.maybeRotateSession(this.now());
|
|
3605
3696
|
if (this.sealedForIdle) {
|
|
3606
3697
|
return;
|
|
@@ -3626,9 +3717,18 @@ var MobileReplayRecorder = class {
|
|
|
3626
3717
|
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
3627
3718
|
return;
|
|
3628
3719
|
}
|
|
3720
|
+
const input = this.noteUserInput(this.now());
|
|
3721
|
+
let resumeCapture = null;
|
|
3722
|
+
if (input === "resumed") {
|
|
3723
|
+
this.startTimers();
|
|
3724
|
+
resumeCapture = this.captureTick(true);
|
|
3725
|
+
}
|
|
3629
3726
|
const generation = this.lifecycleGeneration;
|
|
3630
3727
|
const operation = this.touchQueue.then(
|
|
3631
3728
|
async () => {
|
|
3729
|
+
if (resumeCapture) {
|
|
3730
|
+
await resumeCapture;
|
|
3731
|
+
}
|
|
3632
3732
|
await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
|
|
3633
3733
|
}
|
|
3634
3734
|
);
|
|
@@ -3682,8 +3782,9 @@ var MobileReplayRecorder = class {
|
|
|
3682
3782
|
}
|
|
3683
3783
|
}
|
|
3684
3784
|
getDiagnostics() {
|
|
3785
|
+
const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
|
|
3685
3786
|
return {
|
|
3686
|
-
status
|
|
3787
|
+
status,
|
|
3687
3788
|
sessionId: this.identity?.sessionId ?? null,
|
|
3688
3789
|
recorderKind: MOBILE_RECORDER_KIND,
|
|
3689
3790
|
configEpoch: this.config?.configEpoch ?? 0,
|
|
@@ -3765,11 +3866,18 @@ var MobileReplayRecorder = class {
|
|
|
3765
3866
|
}
|
|
3766
3867
|
await this.transport?.restore();
|
|
3767
3868
|
}
|
|
3768
|
-
|
|
3869
|
+
/*
|
|
3870
|
+
* mayPauseForIdle is false only for the snapshot captureSession() takes
|
|
3871
|
+
* while paused, whose marker must be in before the pause check runs.
|
|
3872
|
+
*/
|
|
3873
|
+
async captureTick(force = false, mayPauseForIdle = true) {
|
|
3769
3874
|
const generation = this.lifecycleGeneration;
|
|
3770
3875
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
|
|
3771
3876
|
return;
|
|
3772
3877
|
}
|
|
3878
|
+
if (this.isIdlePaused()) {
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3773
3881
|
this.captureGeneration = generation;
|
|
3774
3882
|
try {
|
|
3775
3883
|
const nowUnixMs = this.now();
|
|
@@ -3785,6 +3893,9 @@ var MobileReplayRecorder = class {
|
|
|
3785
3893
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
|
|
3786
3894
|
return;
|
|
3787
3895
|
}
|
|
3896
|
+
if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3788
3899
|
const capturedSessionId = this.identity?.sessionId;
|
|
3789
3900
|
const tree = await this.nativeViewTree.captureViewTree(this.rootTag);
|
|
3790
3901
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.rotatingSession || !capturedSessionId || this.identity?.sessionId !== capturedSessionId) {
|
|
@@ -3802,6 +3913,9 @@ var MobileReplayRecorder = class {
|
|
|
3802
3913
|
if (this.chunkBuffer.shouldFlush()) {
|
|
3803
3914
|
await this.closeCurrent(false);
|
|
3804
3915
|
}
|
|
3916
|
+
if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
|
|
3917
|
+
await this.pauseForIdle(nowUnixMs, generation);
|
|
3918
|
+
}
|
|
3805
3919
|
} catch (error) {
|
|
3806
3920
|
if (this.running && this.isCurrentGeneration(generation)) {
|
|
3807
3921
|
this.disable("native-capture-failed", {
|
|
@@ -3833,6 +3947,163 @@ var MobileReplayRecorder = class {
|
|
|
3833
3947
|
if (capture.hasFullSnapshot) {
|
|
3834
3948
|
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
3835
3949
|
this.forceFullSnapshot = false;
|
|
3950
|
+
this.completeIdleResume(nowUnixMs);
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
/*
|
|
3954
|
+
* The person did something: a touch, the soft keyboard showing or
|
|
3955
|
+
* hiding, or the app coming back to the foreground. It ends an idle
|
|
3956
|
+
* pause (see endIdlePause).
|
|
3957
|
+
*/
|
|
3958
|
+
noteUserInput(nowUnixMs) {
|
|
3959
|
+
this.lastInputUnixMs = Math.max(this.lastInputUnixMs, nowUnixMs);
|
|
3960
|
+
return this.endIdlePause(nowUnixMs);
|
|
3961
|
+
}
|
|
3962
|
+
/*
|
|
3963
|
+
* What someone being back does to an idle pause, on its own for
|
|
3964
|
+
* captureSession(), which acts on the pause without being input.
|
|
3965
|
+
*
|
|
3966
|
+
* Inside the session's idle window the same session resumes, on the
|
|
3967
|
+
* snapshot of the next capture; the caller starts that capture. Once
|
|
3968
|
+
* the window has run out - or the session already ended idle - the
|
|
3969
|
+
* session is over, and the caller starts the next one through
|
|
3970
|
+
* maybeRotateForUser, as on any idle session; nothing changes here.
|
|
3971
|
+
*/
|
|
3972
|
+
endIdlePause(nowUnixMs) {
|
|
3973
|
+
if (this.sealedForIdle) {
|
|
3974
|
+
return "session-ended";
|
|
3975
|
+
}
|
|
3976
|
+
if (!this.isIdlePaused()) {
|
|
3977
|
+
return "none";
|
|
3978
|
+
}
|
|
3979
|
+
if (this.rotationReason(nowUnixMs) !== null) {
|
|
3980
|
+
return "session-ended";
|
|
3981
|
+
}
|
|
3982
|
+
this.idleResumeBacklog = [];
|
|
3983
|
+
this.forceFullSnapshot = true;
|
|
3984
|
+
this.sessionLastActivityUnixMs = Math.max(
|
|
3985
|
+
this.sessionLastActivityUnixMs,
|
|
3986
|
+
nowUnixMs
|
|
3987
|
+
);
|
|
3988
|
+
return "resumed";
|
|
3989
|
+
}
|
|
3990
|
+
/* Paused for idle with nobody back yet: nothing is captured or recorded. */
|
|
3991
|
+
isIdlePaused() {
|
|
3992
|
+
return this.idlePause !== null && this.idleResumeBacklog === null;
|
|
3993
|
+
}
|
|
3994
|
+
/*
|
|
3995
|
+
* Whether the pause's marker is in the stream being recorded now, which
|
|
3996
|
+
* is what owes it the idle-resumed marker and dates a stop at it.
|
|
3997
|
+
*/
|
|
3998
|
+
isPauseInStream(pause) {
|
|
3999
|
+
return pause.marked && this.identity?.sessionId === pause.sessionId && this.identity?.tabId === pause.tabId;
|
|
4000
|
+
}
|
|
4001
|
+
/*
|
|
4002
|
+
* Stop capturing: nobody has touched the app for the pause window.
|
|
4003
|
+
*
|
|
4004
|
+
* In this order: the idle-paused marker goes in as the last event of the
|
|
4005
|
+
* open chunk, that chunk goes out through the ordinary path, and the
|
|
4006
|
+
* sampler stops. So the last thing the recording holds before the
|
|
4007
|
+
* stretch nobody recorded says so - and a session the person never comes
|
|
4008
|
+
* back to has already ended there when its idle window runs out, so the
|
|
4009
|
+
* idle seal (sealForIdle) has nothing left to send.
|
|
4010
|
+
*
|
|
4011
|
+
* A recorder holding its pre-roll in memory (error-triggered, or waiting
|
|
4012
|
+
* for consent) has nothing to mark and nothing to send. Its pre-roll is
|
|
4013
|
+
* dropped instead: a trigger once the person is back must not upload a
|
|
4014
|
+
* minute of nobody in front of a snapshot taken after an unrecorded gap.
|
|
4015
|
+
*/
|
|
4016
|
+
async pauseForIdle(nowUnixMs, generation) {
|
|
4017
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.idlePause !== null || this.sealedForIdle || !this.identity) {
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
const paused = {
|
|
4021
|
+
/*
|
|
4022
|
+
* Never before the session began: one that started with nobody there
|
|
4023
|
+
* (the chunk cap rolled it over) was not idle before it existed.
|
|
4024
|
+
*/
|
|
4025
|
+
idleSinceUnixMs: Math.min(
|
|
4026
|
+
nowUnixMs,
|
|
4027
|
+
Math.max(this.lastInputUnixMs, this.identity.sessionStartUnixMs)
|
|
4028
|
+
),
|
|
4029
|
+
pausedAtUnixMs: nowUnixMs
|
|
4030
|
+
};
|
|
4031
|
+
const marked = this.uploadActive;
|
|
4032
|
+
this.idlePause = {
|
|
4033
|
+
sessionId: this.identity.sessionId,
|
|
4034
|
+
tabId: this.identity.tabId,
|
|
4035
|
+
idleSinceUnixMs: paused.idleSinceUnixMs,
|
|
4036
|
+
pausedAtUnixMs: paused.pausedAtUnixMs,
|
|
4037
|
+
marked
|
|
4038
|
+
};
|
|
4039
|
+
this.stopCaptureTimer();
|
|
4040
|
+
this.diagnostic("idle-paused", {
|
|
4041
|
+
idleForMs: nowUnixMs - paused.idleSinceUnixMs
|
|
4042
|
+
});
|
|
4043
|
+
if (!marked) {
|
|
4044
|
+
this.chunkBuffer.clear();
|
|
4045
|
+
this.rollingBuffer.clear();
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
this.appendMarker(IDLE_PAUSED_CUSTOM_EVENT_TAG, paused, nowUnixMs);
|
|
4049
|
+
await this.closeCurrent(false);
|
|
4050
|
+
}
|
|
4051
|
+
/*
|
|
4052
|
+
* The snapshot the session resumes on is in the buffer: the idle-resumed
|
|
4053
|
+
* marker goes directly behind it, then whatever was recorded since the
|
|
4054
|
+
* input that woke the recorder. Same session, same tab and - the pause
|
|
4055
|
+
* having closed its chunk - the next chunk index.
|
|
4056
|
+
*/
|
|
4057
|
+
completeIdleResume(nowUnixMs) {
|
|
4058
|
+
const pause = this.idlePause;
|
|
4059
|
+
const held = this.idleResumeBacklog;
|
|
4060
|
+
if (pause === null || held === null) {
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
this.idlePause = null;
|
|
4064
|
+
this.idleResumeBacklog = null;
|
|
4065
|
+
const resumedAtUnixMs = Math.max(pause.pausedAtUnixMs, nowUnixMs);
|
|
4066
|
+
if (this.isPauseInStream(pause)) {
|
|
4067
|
+
const resumed = {
|
|
4068
|
+
pausedAtUnixMs: pause.pausedAtUnixMs,
|
|
4069
|
+
resumedAtUnixMs
|
|
4070
|
+
};
|
|
4071
|
+
this.appendMarker(
|
|
4072
|
+
IDLE_RESUMED_CUSTOM_EVENT_TAG,
|
|
4073
|
+
resumed,
|
|
4074
|
+
resumedAtUnixMs
|
|
4075
|
+
);
|
|
4076
|
+
}
|
|
4077
|
+
this.releaseHeldEvents(held);
|
|
4078
|
+
this.diagnostic("idle-resumed", {
|
|
4079
|
+
pausedForMs: resumedAtUnixMs - pause.pausedAtUnixMs
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
/*
|
|
4083
|
+
* A marker the recorder writes about itself - the idle pause and resume,
|
|
4084
|
+
* and a stop while paused - straight into the open chunk, stamped with
|
|
4085
|
+
* the moment it stands for. None of them is activity (the idle rollover
|
|
4086
|
+
* must not move for them), and none is "now": a stop while paused is
|
|
4087
|
+
* dated at the pause.
|
|
4088
|
+
*/
|
|
4089
|
+
appendMarker(tag, payload, timestamp) {
|
|
4090
|
+
const options = {};
|
|
4091
|
+
if (this.options) {
|
|
4092
|
+
options.route = toAppUrl(this.options.mobileAppIdentifier, this.route);
|
|
4093
|
+
}
|
|
4094
|
+
if (!this.chunkBuffer.append(
|
|
4095
|
+
createCustomEvent(tag, payload, timestamp),
|
|
4096
|
+
options
|
|
4097
|
+
)) {
|
|
4098
|
+
this.droppedEvents += 1;
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
/* What was held for a resume, in the order it was recorded. */
|
|
4102
|
+
releaseHeldEvents(held) {
|
|
4103
|
+
for (const entry of held) {
|
|
4104
|
+
if (!this.chunkBuffer.append(entry.event, entry.options)) {
|
|
4105
|
+
this.droppedEvents += 1;
|
|
4106
|
+
}
|
|
3836
4107
|
}
|
|
3837
4108
|
}
|
|
3838
4109
|
appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
|
|
@@ -3854,10 +4125,9 @@ var MobileReplayRecorder = class {
|
|
|
3854
4125
|
this.diagnostic("invalid-event-timestamp");
|
|
3855
4126
|
return;
|
|
3856
4127
|
}
|
|
3857
|
-
this.
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
);
|
|
4128
|
+
if (this.isIdlePaused()) {
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
3861
4131
|
const appendOptions = {};
|
|
3862
4132
|
if (this.options) {
|
|
3863
4133
|
appendOptions.route = toAppUrl(
|
|
@@ -3868,6 +4138,18 @@ var MobileReplayRecorder = class {
|
|
|
3868
4138
|
if (signal) {
|
|
3869
4139
|
appendOptions.signal = signal;
|
|
3870
4140
|
}
|
|
4141
|
+
if (this.idleResumeBacklog !== null) {
|
|
4142
|
+
if (this.idleResumeBacklog.length < MAX_EVENTS_HELD_FOR_RESUME) {
|
|
4143
|
+
this.idleResumeBacklog.push({ event, options: appendOptions });
|
|
4144
|
+
} else {
|
|
4145
|
+
this.droppedEvents += 1;
|
|
4146
|
+
}
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
this.sessionLastActivityUnixMs = Math.max(
|
|
4150
|
+
this.sessionLastActivityUnixMs,
|
|
4151
|
+
event.timestamp
|
|
4152
|
+
);
|
|
3871
4153
|
const accepted = this.chunkBuffer.append(event, appendOptions);
|
|
3872
4154
|
if (!accepted) {
|
|
3873
4155
|
this.droppedEvents += 1;
|
|
@@ -4053,6 +4335,63 @@ var MobileReplayRecorder = class {
|
|
|
4053
4335
|
});
|
|
4054
4336
|
}
|
|
4055
4337
|
);
|
|
4338
|
+
this.installKeyboardListeners();
|
|
4339
|
+
}
|
|
4340
|
+
installKeyboardListeners() {
|
|
4341
|
+
this.removeKeyboardListeners();
|
|
4342
|
+
const keyboard = this.keyboard;
|
|
4343
|
+
this.softKeyboardVisible = false;
|
|
4344
|
+
if (!keyboard || typeof keyboard.addListener !== "function") {
|
|
4345
|
+
return;
|
|
4346
|
+
}
|
|
4347
|
+
try {
|
|
4348
|
+
this.softKeyboardVisible = keyboard.isVisible?.() === true;
|
|
4349
|
+
} catch {
|
|
4350
|
+
this.softKeyboardVisible = false;
|
|
4351
|
+
}
|
|
4352
|
+
try {
|
|
4353
|
+
this.keyboardSubscriptions.push(
|
|
4354
|
+
keyboard.addListener("keyboardDidShow", () => {
|
|
4355
|
+
this.onKeyboardChange(true);
|
|
4356
|
+
}),
|
|
4357
|
+
keyboard.addListener("keyboardDidHide", () => {
|
|
4358
|
+
this.onKeyboardChange(false);
|
|
4359
|
+
})
|
|
4360
|
+
);
|
|
4361
|
+
} catch {
|
|
4362
|
+
this.removeKeyboardListeners();
|
|
4363
|
+
this.softKeyboardVisible = false;
|
|
4364
|
+
this.diagnostic("keyboard-subscribe-failed");
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
removeKeyboardListeners() {
|
|
4368
|
+
const subscriptions = this.keyboardSubscriptions;
|
|
4369
|
+
this.keyboardSubscriptions = [];
|
|
4370
|
+
for (const subscription of subscriptions) {
|
|
4371
|
+
try {
|
|
4372
|
+
subscription.remove();
|
|
4373
|
+
} catch {
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
/*
|
|
4378
|
+
* The soft keyboard came up or went away. That is the person - a field
|
|
4379
|
+
* tapped, a message sent - so it counts as input and resumes a paused
|
|
4380
|
+
* recording the way a touch does; and while it is up the idle pause
|
|
4381
|
+
* waits, since what is typed on it reaches no view the recorder sees.
|
|
4382
|
+
*/
|
|
4383
|
+
onKeyboardChange(visible) {
|
|
4384
|
+
this.softKeyboardVisible = visible;
|
|
4385
|
+
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4388
|
+
const input = this.noteUserInput(this.now());
|
|
4389
|
+
if (input === "resumed") {
|
|
4390
|
+
this.startTimers();
|
|
4391
|
+
void this.captureTick(true);
|
|
4392
|
+
} else if (input === "session-ended") {
|
|
4393
|
+
void this.maybeRotateForUser(this.now());
|
|
4394
|
+
}
|
|
4056
4395
|
}
|
|
4057
4396
|
async onAppStateChange(state, generation) {
|
|
4058
4397
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
@@ -4072,6 +4411,13 @@ var MobileReplayRecorder = class {
|
|
|
4072
4411
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4073
4412
|
return;
|
|
4074
4413
|
}
|
|
4414
|
+
if (this.idlePause !== null || this.sealedForIdle) {
|
|
4415
|
+
this.idleResumeBacklog = null;
|
|
4416
|
+
if (this.uploadActive) {
|
|
4417
|
+
await this.transport?.drain();
|
|
4418
|
+
}
|
|
4419
|
+
return;
|
|
4420
|
+
}
|
|
4075
4421
|
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
|
|
4076
4422
|
await this.closeCurrent(false);
|
|
4077
4423
|
if (this.uploadActive) {
|
|
@@ -4081,6 +4427,7 @@ var MobileReplayRecorder = class {
|
|
|
4081
4427
|
}
|
|
4082
4428
|
if (isActive && !this.foreground) {
|
|
4083
4429
|
this.foreground = true;
|
|
4430
|
+
const input = this.noteUserInput(this.now());
|
|
4084
4431
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4085
4432
|
generation,
|
|
4086
4433
|
true,
|
|
@@ -4094,7 +4441,10 @@ var MobileReplayRecorder = class {
|
|
|
4094
4441
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4095
4442
|
return;
|
|
4096
4443
|
}
|
|
4097
|
-
|
|
4444
|
+
const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
|
|
4445
|
+
if (!resumedPausedSession) {
|
|
4446
|
+
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
|
|
4447
|
+
}
|
|
4098
4448
|
const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
|
|
4099
4449
|
if (!openedOnSnapshot) {
|
|
4100
4450
|
this.forceFullSnapshot = true;
|
|
@@ -4120,16 +4470,31 @@ var MobileReplayRecorder = class {
|
|
|
4120
4470
|
void this.captureTick();
|
|
4121
4471
|
}, intervalMs);
|
|
4122
4472
|
this.flushTimer = setInterval(() => {
|
|
4473
|
+
if (this.isIdlePaused()) {
|
|
4474
|
+
if (this.acceptingEvents) {
|
|
4475
|
+
void this.maybeRotateSession(this.now());
|
|
4476
|
+
}
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4123
4479
|
if (this.acceptingEvents) {
|
|
4124
4480
|
void this.closeCurrent(false);
|
|
4125
4481
|
}
|
|
4126
4482
|
}, SESSION_REPLAY_FLUSH_INTERVAL_MS);
|
|
4127
4483
|
}
|
|
4128
|
-
|
|
4484
|
+
/*
|
|
4485
|
+
* The sampler alone. An idle pause stops it - every tick is a native
|
|
4486
|
+
* traversal of the whole view tree, which is what a pause is for not
|
|
4487
|
+
* paying - and keeps the 15-second flush timer as its heartbeat, rather
|
|
4488
|
+
* than waking the JS thread twice a second to find nothing to do.
|
|
4489
|
+
*/
|
|
4490
|
+
stopCaptureTimer() {
|
|
4129
4491
|
if (this.captureTimer) {
|
|
4130
4492
|
clearInterval(this.captureTimer);
|
|
4131
4493
|
this.captureTimer = null;
|
|
4132
4494
|
}
|
|
4495
|
+
}
|
|
4496
|
+
clearTimers() {
|
|
4497
|
+
this.stopCaptureTimer();
|
|
4133
4498
|
if (this.flushTimer) {
|
|
4134
4499
|
clearInterval(this.flushTimer);
|
|
4135
4500
|
this.flushTimer = null;
|
|
@@ -4221,6 +4586,7 @@ var MobileReplayRecorder = class {
|
|
|
4221
4586
|
this.clearTimers();
|
|
4222
4587
|
this.appStateSubscription?.remove();
|
|
4223
4588
|
this.appStateSubscription = null;
|
|
4589
|
+
this.removeKeyboardListeners();
|
|
4224
4590
|
this.stopConnectivity();
|
|
4225
4591
|
this.restoreGlobalErrorHandler();
|
|
4226
4592
|
}
|
|
@@ -4381,8 +4747,12 @@ var MobileReplayRecorder = class {
|
|
|
4381
4747
|
if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
|
|
4382
4748
|
return;
|
|
4383
4749
|
}
|
|
4384
|
-
if (!this.rotationPromise && !this.sealedForIdle
|
|
4385
|
-
|
|
4750
|
+
if (!this.rotationPromise && !this.sealedForIdle) {
|
|
4751
|
+
if (this.isIdlePaused()) {
|
|
4752
|
+
await this.sealForIdleNow();
|
|
4753
|
+
} else if (this.rotationReason(this.now()) === "idle") {
|
|
4754
|
+
await this.maybeRotateSession(this.now());
|
|
4755
|
+
}
|
|
4386
4756
|
}
|
|
4387
4757
|
if (this.rotationPromise) {
|
|
4388
4758
|
await this.rotationPromise;
|
|
@@ -4798,6 +5168,9 @@ var MobileReplayRecorder = class {
|
|
|
4798
5168
|
reason = "idle";
|
|
4799
5169
|
} else {
|
|
4800
5170
|
reason = this.rotationReason(nowUnixMs);
|
|
5171
|
+
if (reason !== null && this.isIdlePaused()) {
|
|
5172
|
+
reason = "idle";
|
|
5173
|
+
}
|
|
4801
5174
|
}
|
|
4802
5175
|
if (!reason) {
|
|
4803
5176
|
return;
|
|
@@ -4814,14 +5187,36 @@ var MobileReplayRecorder = class {
|
|
|
4814
5187
|
this.rotatingSession = false;
|
|
4815
5188
|
}
|
|
4816
5189
|
}
|
|
5190
|
+
/*
|
|
5191
|
+
* The idle seal on demand, under the lock every rotation takes: for an
|
|
5192
|
+
* identity change on a session paused for idle, which no clock has ended
|
|
5193
|
+
* yet but nobody is in.
|
|
5194
|
+
*/
|
|
5195
|
+
async sealForIdleNow() {
|
|
5196
|
+
if (this.rotationPromise || this.rotatingSession || this.sealedForIdle) {
|
|
5197
|
+
return;
|
|
5198
|
+
}
|
|
5199
|
+
this.rotatingSession = true;
|
|
5200
|
+
const operation = this.sealForIdle();
|
|
5201
|
+
this.rotationPromise = operation;
|
|
5202
|
+
try {
|
|
5203
|
+
await operation;
|
|
5204
|
+
} finally {
|
|
5205
|
+
if (this.rotationPromise === operation) {
|
|
5206
|
+
this.rotationPromise = null;
|
|
5207
|
+
}
|
|
5208
|
+
this.rotatingSession = false;
|
|
5209
|
+
}
|
|
5210
|
+
}
|
|
4817
5211
|
/*
|
|
4818
5212
|
* maybeRotateSession for a user who is here. On an app whose session
|
|
4819
|
-
* ended idle
|
|
5213
|
+
* ended idle - or was paused for idle, which stops the capture tick too
|
|
5214
|
+
* (#4208) - the policy is brought up to date first: no capture tick has
|
|
4820
5215
|
* refreshed it while the app sat idle, and the next session must not open
|
|
4821
5216
|
* under one that has since changed (or turned replay off).
|
|
4822
5217
|
*/
|
|
4823
5218
|
async maybeRotateForUser(nowUnixMs) {
|
|
4824
|
-
if (this.sealedForIdle && !this.rotatingSession) {
|
|
5219
|
+
if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
|
|
4825
5220
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4826
5221
|
this.lifecycleGeneration,
|
|
4827
5222
|
false,
|
|
@@ -4840,10 +5235,14 @@ var MobileReplayRecorder = class {
|
|
|
4840
5235
|
* never by now - and with no marker of its own: a marker stamped now
|
|
4841
5236
|
* would date the session's end half an hour or more after anything the
|
|
4842
5237
|
* player can show. With nothing buffered nothing is sent, and the server
|
|
4843
|
-
* ends the session at its last chunk
|
|
5238
|
+
* ends the session at its last chunk - for a session paused for idle
|
|
5239
|
+
* (#4208), the chunk its idle-paused marker closed.
|
|
4844
5240
|
*/
|
|
4845
5241
|
async sealForIdle() {
|
|
4846
5242
|
this.sealedForIdle = true;
|
|
5243
|
+
this.idlePause = null;
|
|
5244
|
+
this.idleResumeBacklog = null;
|
|
5245
|
+
this.clearTimers();
|
|
4847
5246
|
this.diagnostic("session-ended-idle", {
|
|
4848
5247
|
idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
|
|
4849
5248
|
});
|
|
@@ -4858,6 +5257,9 @@ var MobileReplayRecorder = class {
|
|
|
4858
5257
|
} else {
|
|
4859
5258
|
this.rollingBuffer.clear();
|
|
4860
5259
|
}
|
|
5260
|
+
if (this.running && this.identityPersisted) {
|
|
5261
|
+
await this.sessionStore?.end();
|
|
5262
|
+
}
|
|
4861
5263
|
}
|
|
4862
5264
|
async performSessionRotation(nowUnixMs, reason) {
|
|
4863
5265
|
if (reason !== "idle") {
|
|
@@ -4940,6 +5342,8 @@ var MobileReplayRecorder = class {
|
|
|
4940
5342
|
}
|
|
4941
5343
|
this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
|
|
4942
5344
|
this.sealedForIdle = false;
|
|
5345
|
+
this.idlePause = null;
|
|
5346
|
+
this.idleResumeBacklog = null;
|
|
4943
5347
|
this.notifySessionChange();
|
|
4944
5348
|
this.sampled = this.identityPersisted ? deterministicSample(
|
|
4945
5349
|
this.identity.sessionId,
|
|
@@ -4960,6 +5364,10 @@ var MobileReplayRecorder = class {
|
|
|
4960
5364
|
if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
|
|
4961
5365
|
this.disable("not-sampled");
|
|
4962
5366
|
this.haltCapture();
|
|
5367
|
+
return;
|
|
5368
|
+
}
|
|
5369
|
+
if (this.captureTimer === null && this.running && this.foreground) {
|
|
5370
|
+
this.startTimers();
|
|
4963
5371
|
}
|
|
4964
5372
|
}
|
|
4965
5373
|
disable(code, details) {
|