@oneuptime/react-native-replay 14.0.11 → 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 +37 -2
- package/dist/index.cjs +634 -58
- package/dist/index.cjs.map +2 -2
- package/dist/index.js +635 -59
- package/dist/index.js.map +2 -2
- package/dist/types/Contract.d.ts +12 -0
- package/dist/types/Contract.d.ts.map +1 -1
- package/dist/types/MobileReplayRecorder.d.ts +39 -2
- 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",
|
|
@@ -150,6 +151,9 @@ var IDENTIFY_CUSTOM_EVENT_TAG = "oneuptime.identify";
|
|
|
150
151
|
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";
|
|
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";
|
|
153
157
|
|
|
154
158
|
// src/Sanitize.ts
|
|
155
159
|
var MAX_TRAIT_KEYS = 20;
|
|
@@ -2525,6 +2529,18 @@ var ReplaySessionStore = class {
|
|
|
2525
2529
|
};
|
|
2526
2530
|
return { ...this.identity };
|
|
2527
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
|
+
}
|
|
2528
2544
|
async clear() {
|
|
2529
2545
|
this.identity = null;
|
|
2530
2546
|
await Promise.all([
|
|
@@ -2983,6 +2999,7 @@ function isTouchPrivate(privacyMap, x2, y) {
|
|
|
2983
2999
|
var MAX_DIAGNOSTIC_EVENTS = 100;
|
|
2984
3000
|
var TOUCH_MOVE_SAMPLE_MS = 50;
|
|
2985
3001
|
var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
|
|
3002
|
+
var MAX_EVENTS_HELD_FOR_RESUME = 500;
|
|
2986
3003
|
var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
2987
3004
|
"AggregateError",
|
|
2988
3005
|
"Error",
|
|
@@ -3054,6 +3071,7 @@ var MobileReplayRecorder = class {
|
|
|
3054
3071
|
__publicField(this, "storage");
|
|
3055
3072
|
__publicField(this, "nativeViewTree");
|
|
3056
3073
|
__publicField(this, "appState");
|
|
3074
|
+
__publicField(this, "keyboard");
|
|
3057
3075
|
__publicField(this, "now");
|
|
3058
3076
|
__publicField(this, "serializer", new ViewTreeSerializer());
|
|
3059
3077
|
__publicField(this, "chunkBuffer", new ReplayChunkBuffer());
|
|
@@ -3120,6 +3138,21 @@ var MobileReplayRecorder = class {
|
|
|
3120
3138
|
__publicField(this, "pendingStartCancellation", null);
|
|
3121
3139
|
__publicField(this, "identityPersisted", false);
|
|
3122
3140
|
__publicField(this, "sessionLastActivityUnixMs", 0);
|
|
3141
|
+
/*
|
|
3142
|
+
* The session went the idle window without activity, was sealed where it
|
|
3143
|
+
* ended, and nobody has come back since.
|
|
3144
|
+
*
|
|
3145
|
+
* Rotating on the spot instead (the old behaviour) started a session
|
|
3146
|
+
* nobody was in: an app left open in the foreground with the screen on
|
|
3147
|
+
* re-snapshots every minute, so it filed a new session every half hour
|
|
3148
|
+
* for as long as it stayed open. Now nothing is captured, uploaded or
|
|
3149
|
+
* handed to the host as a session id until the user is back - a touch,
|
|
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.
|
|
3154
|
+
*/
|
|
3155
|
+
__publicField(this, "sealedForIdle", false);
|
|
3123
3156
|
__publicField(this, "rotatingSession", false);
|
|
3124
3157
|
__publicField(this, "rotationPromise", null);
|
|
3125
3158
|
__publicField(this, "appStateQueue", Promise.resolve());
|
|
@@ -3136,9 +3169,58 @@ var MobileReplayRecorder = class {
|
|
|
3136
3169
|
__publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
|
|
3137
3170
|
__publicField(this, "unidentifiedTouchCount", 0);
|
|
3138
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", []);
|
|
3139
3220
|
this.storage = dependencies.storage ?? defaultReplayStorage;
|
|
3140
3221
|
this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
|
|
3141
3222
|
this.appState = dependencies.appState ?? import_react_native2.AppState;
|
|
3223
|
+
this.keyboard = dependencies.keyboard ?? import_react_native2.Keyboard ?? null;
|
|
3142
3224
|
this.now = dependencies.now ?? defaultNow;
|
|
3143
3225
|
}
|
|
3144
3226
|
start(options) {
|
|
@@ -3207,6 +3289,7 @@ var MobileReplayRecorder = class {
|
|
|
3207
3289
|
this.appStateQueue = Promise.resolve();
|
|
3208
3290
|
this.rotationPromise = null;
|
|
3209
3291
|
this.rotatingSession = false;
|
|
3292
|
+
this.sealedForIdle = false;
|
|
3210
3293
|
this.policyRefreshPromise = null;
|
|
3211
3294
|
this.lastPolicyRefreshAtUnixMs = 0;
|
|
3212
3295
|
this.forceFullSnapshot = true;
|
|
@@ -3214,6 +3297,8 @@ var MobileReplayRecorder = class {
|
|
|
3214
3297
|
this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
|
|
3215
3298
|
this.touchQueue = Promise.resolve();
|
|
3216
3299
|
this.resetTouchSequences();
|
|
3300
|
+
this.idlePause = null;
|
|
3301
|
+
this.idleResumeBacklog = null;
|
|
3217
3302
|
const validated = validateStartOptions(options);
|
|
3218
3303
|
if (!validated) {
|
|
3219
3304
|
this.disable("invalid-start-options");
|
|
@@ -3293,6 +3378,7 @@ var MobileReplayRecorder = class {
|
|
|
3293
3378
|
);
|
|
3294
3379
|
}
|
|
3295
3380
|
this.sessionLastActivityUnixMs = nowUnixMs;
|
|
3381
|
+
this.lastInputUnixMs = nowUnixMs;
|
|
3296
3382
|
if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
|
|
3297
3383
|
this.disable("not-sampled");
|
|
3298
3384
|
return false;
|
|
@@ -3379,12 +3465,23 @@ var MobileReplayRecorder = class {
|
|
|
3379
3465
|
this.diagnosticsStatus = "stopped";
|
|
3380
3466
|
return;
|
|
3381
3467
|
}
|
|
3382
|
-
this.
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3468
|
+
const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
|
|
3469
|
+
const pause = this.idlePause;
|
|
3470
|
+
if (!endedIdle && pause === null) {
|
|
3471
|
+
this.appendCustom(
|
|
3472
|
+
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3473
|
+
{ state: "stopped" },
|
|
3474
|
+
void 0,
|
|
3475
|
+
true
|
|
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
|
+
);
|
|
3484
|
+
}
|
|
3388
3485
|
this.haltCapture();
|
|
3389
3486
|
await this.closeCurrent(true);
|
|
3390
3487
|
if (this.uploadActive) {
|
|
@@ -3532,7 +3629,7 @@ var MobileReplayRecorder = class {
|
|
|
3532
3629
|
}
|
|
3533
3630
|
async setRoute(route) {
|
|
3534
3631
|
await this.identityQueue;
|
|
3535
|
-
if (this.running && !this.acceptingEvents) {
|
|
3632
|
+
if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
|
|
3536
3633
|
this.route = sanitizeRoute(route);
|
|
3537
3634
|
return;
|
|
3538
3635
|
}
|
|
@@ -3547,6 +3644,9 @@ var MobileReplayRecorder = class {
|
|
|
3547
3644
|
return;
|
|
3548
3645
|
}
|
|
3549
3646
|
this.route = next;
|
|
3647
|
+
if (this.sealedForIdle) {
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3550
3650
|
this.appendCustom(
|
|
3551
3651
|
ROUTE_CUSTOM_EVENT_TAG,
|
|
3552
3652
|
{
|
|
@@ -3565,7 +3665,15 @@ var MobileReplayRecorder = class {
|
|
|
3565
3665
|
if (!this.running || !this.acceptingEvents) {
|
|
3566
3666
|
return;
|
|
3567
3667
|
}
|
|
3568
|
-
|
|
3668
|
+
const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
|
|
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
|
+
}
|
|
3569
3677
|
const safeReason2 = truncate(
|
|
3570
3678
|
(typeof reason === "string" ? reason.trim() : "") || "manual",
|
|
3571
3679
|
MAX_CAPTURE_REASON_LENGTH
|
|
@@ -3581,7 +3689,13 @@ var MobileReplayRecorder = class {
|
|
|
3581
3689
|
if (!this.running || !this.acceptingEvents) {
|
|
3582
3690
|
return;
|
|
3583
3691
|
}
|
|
3692
|
+
if (this.isIdlePaused()) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3584
3695
|
await this.maybeRotateSession(this.now());
|
|
3696
|
+
if (this.sealedForIdle) {
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3585
3699
|
const rawName = error instanceof Error && typeof error.name === "string" ? error.name.trim() : "";
|
|
3586
3700
|
const name = SAFE_ERROR_NAMES.has(rawName) ? rawName : "Error";
|
|
3587
3701
|
this.appendCustom(
|
|
@@ -3603,9 +3717,18 @@ var MobileReplayRecorder = class {
|
|
|
3603
3717
|
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
3604
3718
|
return;
|
|
3605
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
|
+
}
|
|
3606
3726
|
const generation = this.lifecycleGeneration;
|
|
3607
3727
|
const operation = this.touchQueue.then(
|
|
3608
3728
|
async () => {
|
|
3729
|
+
if (resumeCapture) {
|
|
3730
|
+
await resumeCapture;
|
|
3731
|
+
}
|
|
3609
3732
|
await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
|
|
3610
3733
|
}
|
|
3611
3734
|
);
|
|
@@ -3632,9 +3755,9 @@ var MobileReplayRecorder = class {
|
|
|
3632
3755
|
this.lastTouchMoveAtUnixMs = touch.timestamp;
|
|
3633
3756
|
}
|
|
3634
3757
|
const nowUnixMs = this.now();
|
|
3635
|
-
if (this.rotatingSession || this.rotationReason(nowUnixMs)) {
|
|
3758
|
+
if (this.rotatingSession || this.sealedForIdle || this.rotationReason(nowUnixMs)) {
|
|
3636
3759
|
this.diagnostic("touch-dropped-session-rotation");
|
|
3637
|
-
void this.
|
|
3760
|
+
void this.maybeRotateForUser(nowUnixMs);
|
|
3638
3761
|
return;
|
|
3639
3762
|
}
|
|
3640
3763
|
this.appendEvent(
|
|
@@ -3659,8 +3782,9 @@ var MobileReplayRecorder = class {
|
|
|
3659
3782
|
}
|
|
3660
3783
|
}
|
|
3661
3784
|
getDiagnostics() {
|
|
3785
|
+
const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
|
|
3662
3786
|
return {
|
|
3663
|
-
status
|
|
3787
|
+
status,
|
|
3664
3788
|
sessionId: this.identity?.sessionId ?? null,
|
|
3665
3789
|
recorderKind: MOBILE_RECORDER_KIND,
|
|
3666
3790
|
configEpoch: this.config?.configEpoch ?? 0,
|
|
@@ -3682,9 +3806,14 @@ var MobileReplayRecorder = class {
|
|
|
3682
3806
|
)
|
|
3683
3807
|
};
|
|
3684
3808
|
}
|
|
3685
|
-
/**
|
|
3809
|
+
/**
|
|
3810
|
+
* Current consented replay session for host trace/log correlation. None
|
|
3811
|
+
* while the session has ended idle and nobody is back: a background poll
|
|
3812
|
+
* on an idle app is no part of a recording that ended at the user's last
|
|
3813
|
+
* activity.
|
|
3814
|
+
*/
|
|
3686
3815
|
getSessionId() {
|
|
3687
|
-
return this.running && this.identityPersisted ? this.identity?.sessionId ?? null : null;
|
|
3816
|
+
return this.running && this.identityPersisted && !this.sealedForIdle ? this.identity?.sessionId ?? null : null;
|
|
3688
3817
|
}
|
|
3689
3818
|
/** Fires immediately when a session exists and again on rotate/clear. */
|
|
3690
3819
|
onSessionChange(listener) {
|
|
@@ -3737,9 +3866,16 @@ var MobileReplayRecorder = class {
|
|
|
3737
3866
|
}
|
|
3738
3867
|
await this.transport?.restore();
|
|
3739
3868
|
}
|
|
3740
|
-
|
|
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) {
|
|
3741
3874
|
const generation = this.lifecycleGeneration;
|
|
3742
|
-
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.captureGeneration === generation || !this.options) {
|
|
3875
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
if (this.isIdlePaused()) {
|
|
3743
3879
|
return;
|
|
3744
3880
|
}
|
|
3745
3881
|
this.captureGeneration = generation;
|
|
@@ -3754,7 +3890,10 @@ var MobileReplayRecorder = class {
|
|
|
3754
3890
|
return;
|
|
3755
3891
|
}
|
|
3756
3892
|
await this.maybeRotateSession(nowUnixMs);
|
|
3757
|
-
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation)) {
|
|
3893
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
|
|
3894
|
+
return;
|
|
3895
|
+
}
|
|
3896
|
+
if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
|
|
3758
3897
|
return;
|
|
3759
3898
|
}
|
|
3760
3899
|
const capturedSessionId = this.identity?.sessionId;
|
|
@@ -3763,7 +3902,6 @@ var MobileReplayRecorder = class {
|
|
|
3763
3902
|
return;
|
|
3764
3903
|
}
|
|
3765
3904
|
this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
|
|
3766
|
-
const size = getViewport();
|
|
3767
3905
|
const shouldForce = force || this.forceFullSnapshot || nowUnixMs - this.lastFullSnapshotAtUnixMs >= SESSION_REPLAY_CHECKOUT_INTERVAL_MS;
|
|
3768
3906
|
if (shouldForce && !this.chunkBuffer.isEmpty()) {
|
|
3769
3907
|
await this.closeCurrent(false);
|
|
@@ -3771,25 +3909,13 @@ var MobileReplayRecorder = class {
|
|
|
3771
3909
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== capturedSessionId) {
|
|
3772
3910
|
return;
|
|
3773
3911
|
}
|
|
3774
|
-
|
|
3775
|
-
timestamp: nowUnixMs,
|
|
3776
|
-
url: toAppUrl(this.options.mobileAppIdentifier, this.route),
|
|
3777
|
-
viewportWidth: size.width,
|
|
3778
|
-
viewportHeight: size.height,
|
|
3779
|
-
forceFullSnapshot: shouldForce
|
|
3780
|
-
});
|
|
3781
|
-
this.droppedEvents += capture.droppedNodes;
|
|
3782
|
-
this.chunkBuffer.appendMany(capture.events, {
|
|
3783
|
-
route: toAppUrl(this.options.mobileAppIdentifier, this.route),
|
|
3784
|
-
fidelityNotices: capture.fidelityNotices
|
|
3785
|
-
});
|
|
3786
|
-
if (capture.hasFullSnapshot) {
|
|
3787
|
-
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
3788
|
-
this.forceFullSnapshot = false;
|
|
3789
|
-
}
|
|
3912
|
+
this.appendCapture(tree, this.options, nowUnixMs, shouldForce);
|
|
3790
3913
|
if (this.chunkBuffer.shouldFlush()) {
|
|
3791
3914
|
await this.closeCurrent(false);
|
|
3792
3915
|
}
|
|
3916
|
+
if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
|
|
3917
|
+
await this.pauseForIdle(nowUnixMs, generation);
|
|
3918
|
+
}
|
|
3793
3919
|
} catch (error) {
|
|
3794
3920
|
if (this.running && this.isCurrentGeneration(generation)) {
|
|
3795
3921
|
this.disable("native-capture-failed", {
|
|
@@ -3803,6 +3929,183 @@ var MobileReplayRecorder = class {
|
|
|
3803
3929
|
}
|
|
3804
3930
|
}
|
|
3805
3931
|
}
|
|
3932
|
+
appendCapture(tree, options, nowUnixMs, forceFullSnapshot) {
|
|
3933
|
+
const size = getViewport();
|
|
3934
|
+
const url = toAppUrl(options.mobileAppIdentifier, this.route);
|
|
3935
|
+
const capture = this.serializer.capture(tree, {
|
|
3936
|
+
timestamp: nowUnixMs,
|
|
3937
|
+
url,
|
|
3938
|
+
viewportWidth: size.width,
|
|
3939
|
+
viewportHeight: size.height,
|
|
3940
|
+
forceFullSnapshot
|
|
3941
|
+
});
|
|
3942
|
+
this.droppedEvents += capture.droppedNodes;
|
|
3943
|
+
this.chunkBuffer.appendMany(capture.events, {
|
|
3944
|
+
route: url,
|
|
3945
|
+
fidelityNotices: capture.fidelityNotices
|
|
3946
|
+
});
|
|
3947
|
+
if (capture.hasFullSnapshot) {
|
|
3948
|
+
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
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
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
3806
4109
|
appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
|
|
3807
4110
|
this.appendEvent(
|
|
3808
4111
|
createCustomEvent(tag, payload, this.now()),
|
|
@@ -3814,15 +4117,17 @@ var MobileReplayRecorder = class {
|
|
|
3814
4117
|
if (!this.running || !this.acceptingEvents && !allowWhenQuiesced) {
|
|
3815
4118
|
return;
|
|
3816
4119
|
}
|
|
4120
|
+
if (this.sealedForIdle) {
|
|
4121
|
+
return;
|
|
4122
|
+
}
|
|
3817
4123
|
if (!Number.isFinite(event.timestamp)) {
|
|
3818
4124
|
this.droppedEvents += 1;
|
|
3819
4125
|
this.diagnostic("invalid-event-timestamp");
|
|
3820
4126
|
return;
|
|
3821
4127
|
}
|
|
3822
|
-
this.
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
);
|
|
4128
|
+
if (this.isIdlePaused()) {
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
3826
4131
|
const appendOptions = {};
|
|
3827
4132
|
if (this.options) {
|
|
3828
4133
|
appendOptions.route = toAppUrl(
|
|
@@ -3833,6 +4138,18 @@ var MobileReplayRecorder = class {
|
|
|
3833
4138
|
if (signal) {
|
|
3834
4139
|
appendOptions.signal = signal;
|
|
3835
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
|
+
);
|
|
3836
4153
|
const accepted = this.chunkBuffer.append(event, appendOptions);
|
|
3837
4154
|
if (!accepted) {
|
|
3838
4155
|
this.droppedEvents += 1;
|
|
@@ -3842,7 +4159,7 @@ var MobileReplayRecorder = class {
|
|
|
3842
4159
|
}
|
|
3843
4160
|
}
|
|
3844
4161
|
async closeCurrent(isFinal) {
|
|
3845
|
-
const closesFinalChunkSlot = !isFinal && !this.rotatingSession && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
|
|
4162
|
+
const closesFinalChunkSlot = !isFinal && !this.rotatingSession && !this.sealedForIdle && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
|
|
3846
4163
|
if (closesFinalChunkSlot) {
|
|
3847
4164
|
this.rotatingSession = true;
|
|
3848
4165
|
const rotationOptions = {};
|
|
@@ -3854,7 +4171,7 @@ var MobileReplayRecorder = class {
|
|
|
3854
4171
|
}
|
|
3855
4172
|
this.chunkBuffer.append(
|
|
3856
4173
|
createCustomEvent(
|
|
3857
|
-
|
|
4174
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
3858
4175
|
{ reason: "chunk-cap" },
|
|
3859
4176
|
this.now()
|
|
3860
4177
|
),
|
|
@@ -4018,6 +4335,63 @@ var MobileReplayRecorder = class {
|
|
|
4018
4335
|
});
|
|
4019
4336
|
}
|
|
4020
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
|
+
}
|
|
4021
4395
|
}
|
|
4022
4396
|
async onAppStateChange(state, generation) {
|
|
4023
4397
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
@@ -4037,6 +4411,13 @@ var MobileReplayRecorder = class {
|
|
|
4037
4411
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4038
4412
|
return;
|
|
4039
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
|
+
}
|
|
4040
4421
|
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
|
|
4041
4422
|
await this.closeCurrent(false);
|
|
4042
4423
|
if (this.uploadActive) {
|
|
@@ -4046,6 +4427,7 @@ var MobileReplayRecorder = class {
|
|
|
4046
4427
|
}
|
|
4047
4428
|
if (isActive && !this.foreground) {
|
|
4048
4429
|
this.foreground = true;
|
|
4430
|
+
const input = this.noteUserInput(this.now());
|
|
4049
4431
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4050
4432
|
generation,
|
|
4051
4433
|
true,
|
|
@@ -4054,15 +4436,22 @@ var MobileReplayRecorder = class {
|
|
|
4054
4436
|
if (!policyReady) {
|
|
4055
4437
|
return;
|
|
4056
4438
|
}
|
|
4057
|
-
|
|
4439
|
+
const sessionIdBeforeResume = this.identity?.sessionId;
|
|
4440
|
+
await this.maybeRotateSession(this.now(), true);
|
|
4058
4441
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4059
4442
|
return;
|
|
4060
4443
|
}
|
|
4061
|
-
|
|
4062
|
-
|
|
4444
|
+
const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
|
|
4445
|
+
if (!resumedPausedSession) {
|
|
4446
|
+
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
|
|
4447
|
+
}
|
|
4448
|
+
const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
|
|
4449
|
+
if (!openedOnSnapshot) {
|
|
4450
|
+
this.forceFullSnapshot = true;
|
|
4451
|
+
}
|
|
4063
4452
|
this.startTimers();
|
|
4064
4453
|
this.diagnosticsStatus = this.consentState() === "Unknown" ? "consent-required" : "recording";
|
|
4065
|
-
await this.captureTick(
|
|
4454
|
+
await this.captureTick(!openedOnSnapshot);
|
|
4066
4455
|
if (this.uploadActive && this.running) {
|
|
4067
4456
|
this.transport?.resume();
|
|
4068
4457
|
}
|
|
@@ -4081,16 +4470,31 @@ var MobileReplayRecorder = class {
|
|
|
4081
4470
|
void this.captureTick();
|
|
4082
4471
|
}, intervalMs);
|
|
4083
4472
|
this.flushTimer = setInterval(() => {
|
|
4473
|
+
if (this.isIdlePaused()) {
|
|
4474
|
+
if (this.acceptingEvents) {
|
|
4475
|
+
void this.maybeRotateSession(this.now());
|
|
4476
|
+
}
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4084
4479
|
if (this.acceptingEvents) {
|
|
4085
4480
|
void this.closeCurrent(false);
|
|
4086
4481
|
}
|
|
4087
4482
|
}, SESSION_REPLAY_FLUSH_INTERVAL_MS);
|
|
4088
4483
|
}
|
|
4089
|
-
|
|
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() {
|
|
4090
4491
|
if (this.captureTimer) {
|
|
4091
4492
|
clearInterval(this.captureTimer);
|
|
4092
4493
|
this.captureTimer = null;
|
|
4093
4494
|
}
|
|
4495
|
+
}
|
|
4496
|
+
clearTimers() {
|
|
4497
|
+
this.stopCaptureTimer();
|
|
4094
4498
|
if (this.flushTimer) {
|
|
4095
4499
|
clearInterval(this.flushTimer);
|
|
4096
4500
|
this.flushTimer = null;
|
|
@@ -4182,6 +4586,7 @@ var MobileReplayRecorder = class {
|
|
|
4182
4586
|
this.clearTimers();
|
|
4183
4587
|
this.appStateSubscription?.remove();
|
|
4184
4588
|
this.appStateSubscription = null;
|
|
4589
|
+
this.removeKeyboardListeners();
|
|
4185
4590
|
this.stopConnectivity();
|
|
4186
4591
|
this.restoreGlobalErrorHandler();
|
|
4187
4592
|
}
|
|
@@ -4342,9 +4747,16 @@ var MobileReplayRecorder = class {
|
|
|
4342
4747
|
if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
|
|
4343
4748
|
return;
|
|
4344
4749
|
}
|
|
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
|
+
}
|
|
4756
|
+
}
|
|
4345
4757
|
if (this.rotationPromise) {
|
|
4346
4758
|
await this.rotationPromise;
|
|
4347
|
-
} else {
|
|
4759
|
+
} else if (!this.sealedForIdle) {
|
|
4348
4760
|
this.rotatingSession = true;
|
|
4349
4761
|
const rotation = this.performSessionRotation(
|
|
4350
4762
|
this.now(),
|
|
@@ -4679,16 +5091,24 @@ var MobileReplayRecorder = class {
|
|
|
4679
5091
|
chunkIndex: 0
|
|
4680
5092
|
};
|
|
4681
5093
|
}
|
|
5094
|
+
/*
|
|
5095
|
+
* Idle before the duration cap, the order Common's SessionIdentity decides
|
|
5096
|
+
* them in. A session left for the idle window ended where it was left,
|
|
5097
|
+
* even when it has outlived the cap by the time anything notices, and
|
|
5098
|
+
* only an idle rotation dates its end there (see performSessionRotation):
|
|
5099
|
+
* called "duration", a session three hours old put in the background for
|
|
5100
|
+
* two more was sealed two hours after its footage.
|
|
5101
|
+
*/
|
|
4682
5102
|
rotationReason(nowUnixMs) {
|
|
4683
5103
|
if (!this.identity) {
|
|
4684
5104
|
return null;
|
|
4685
5105
|
}
|
|
4686
|
-
if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
|
|
4687
|
-
return "duration";
|
|
4688
|
-
}
|
|
4689
5106
|
if (nowUnixMs - this.sessionLastActivityUnixMs >= SESSION_REPLAY_IDLE_ROLLOVER_MS) {
|
|
4690
5107
|
return "idle";
|
|
4691
5108
|
}
|
|
5109
|
+
if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
|
|
5110
|
+
return "duration";
|
|
5111
|
+
}
|
|
4692
5112
|
return null;
|
|
4693
5113
|
}
|
|
4694
5114
|
runExternalActivity(nowUnixMs, action) {
|
|
@@ -4723,23 +5143,61 @@ var MobileReplayRecorder = class {
|
|
|
4723
5143
|
}
|
|
4724
5144
|
});
|
|
4725
5145
|
}
|
|
4726
|
-
|
|
4727
|
-
|
|
5146
|
+
/*
|
|
5147
|
+
* isUserBack: the caller knows the user is here - a touch, the app
|
|
5148
|
+
* returning to the foreground, an explicit captureSession(). An idle
|
|
5149
|
+
* session then rotates at once. Anything else (a capture tick, a route
|
|
5150
|
+
* change, a developer event) only finds out the session went idle, and
|
|
5151
|
+
* seals it to wait for them (see sealedForIdle).
|
|
5152
|
+
*/
|
|
5153
|
+
async maybeRotateSession(nowUnixMs, isUserBack = false) {
|
|
5154
|
+
while (this.rotationPromise) {
|
|
4728
5155
|
await this.rotationPromise;
|
|
4729
|
-
|
|
5156
|
+
if (!isUserBack || !this.sealedForIdle) {
|
|
5157
|
+
return;
|
|
5158
|
+
}
|
|
4730
5159
|
}
|
|
4731
5160
|
if (!this.running || this.rotatingSession || !this.identity || !this.config || !this.sessionStore || !this.options) {
|
|
4732
5161
|
return;
|
|
4733
5162
|
}
|
|
4734
|
-
|
|
5163
|
+
let reason;
|
|
5164
|
+
if (this.sealedForIdle) {
|
|
5165
|
+
if (!isUserBack) {
|
|
5166
|
+
return;
|
|
5167
|
+
}
|
|
5168
|
+
reason = "idle";
|
|
5169
|
+
} else {
|
|
5170
|
+
reason = this.rotationReason(nowUnixMs);
|
|
5171
|
+
if (reason !== null && this.isIdlePaused()) {
|
|
5172
|
+
reason = "idle";
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
4735
5175
|
if (!reason) {
|
|
4736
5176
|
return;
|
|
4737
5177
|
}
|
|
4738
5178
|
this.rotatingSession = true;
|
|
4739
|
-
const operation = this.performSessionRotation(
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
5179
|
+
const operation = reason === "idle" && !isUserBack ? this.sealForIdle() : this.performSessionRotation(nowUnixMs, reason);
|
|
5180
|
+
this.rotationPromise = operation;
|
|
5181
|
+
try {
|
|
5182
|
+
await operation;
|
|
5183
|
+
} finally {
|
|
5184
|
+
if (this.rotationPromise === operation) {
|
|
5185
|
+
this.rotationPromise = null;
|
|
5186
|
+
}
|
|
5187
|
+
this.rotatingSession = false;
|
|
5188
|
+
}
|
|
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();
|
|
4743
5201
|
this.rotationPromise = operation;
|
|
4744
5202
|
try {
|
|
4745
5203
|
await operation;
|
|
@@ -4750,8 +5208,68 @@ var MobileReplayRecorder = class {
|
|
|
4750
5208
|
this.rotatingSession = false;
|
|
4751
5209
|
}
|
|
4752
5210
|
}
|
|
5211
|
+
/*
|
|
5212
|
+
* maybeRotateSession for a user who is here. On an app whose session
|
|
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
|
|
5215
|
+
* refreshed it while the app sat idle, and the next session must not open
|
|
5216
|
+
* under one that has since changed (or turned replay off).
|
|
5217
|
+
*/
|
|
5218
|
+
async maybeRotateForUser(nowUnixMs) {
|
|
5219
|
+
if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
|
|
5220
|
+
const policyReady = await this.refreshRuntimePolicy(
|
|
5221
|
+
this.lifecycleGeneration,
|
|
5222
|
+
false,
|
|
5223
|
+
"periodic"
|
|
5224
|
+
);
|
|
5225
|
+
if (!policyReady) {
|
|
5226
|
+
return;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
await this.maybeRotateSession(nowUnixMs, true);
|
|
5230
|
+
}
|
|
5231
|
+
/*
|
|
5232
|
+
* End the session as idle, where it really ended, and wait for the user.
|
|
5233
|
+
*
|
|
5234
|
+
* Sealed with whatever footage is still buffered - dated by that footage,
|
|
5235
|
+
* never by now - and with no marker of its own: a marker stamped now
|
|
5236
|
+
* would date the session's end half an hour or more after anything the
|
|
5237
|
+
* player can show. With nothing buffered nothing is sent, and the server
|
|
5238
|
+
* ends the session at its last chunk - for a session paused for idle
|
|
5239
|
+
* (#4208), the chunk its idle-paused marker closed.
|
|
5240
|
+
*/
|
|
5241
|
+
async sealForIdle() {
|
|
5242
|
+
this.sealedForIdle = true;
|
|
5243
|
+
this.idlePause = null;
|
|
5244
|
+
this.idleResumeBacklog = null;
|
|
5245
|
+
this.clearTimers();
|
|
5246
|
+
this.diagnostic("session-ended-idle", {
|
|
5247
|
+
idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
|
|
5248
|
+
});
|
|
5249
|
+
this.notifySessionChange();
|
|
5250
|
+
await this.closeCurrent(true);
|
|
5251
|
+
await this.closeQueue;
|
|
5252
|
+
if (!this.running) {
|
|
5253
|
+
return;
|
|
5254
|
+
}
|
|
5255
|
+
if (this.uploadActive) {
|
|
5256
|
+
await this.transport?.drain();
|
|
5257
|
+
} else {
|
|
5258
|
+
this.rollingBuffer.clear();
|
|
5259
|
+
}
|
|
5260
|
+
if (this.running && this.identityPersisted) {
|
|
5261
|
+
await this.sessionStore?.end();
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
4753
5264
|
async performSessionRotation(nowUnixMs, reason) {
|
|
4754
|
-
|
|
5265
|
+
if (reason !== "idle") {
|
|
5266
|
+
this.appendCustom(
|
|
5267
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
5268
|
+
{ reason },
|
|
5269
|
+
void 0,
|
|
5270
|
+
true
|
|
5271
|
+
);
|
|
5272
|
+
}
|
|
4755
5273
|
await this.closeCurrent(true);
|
|
4756
5274
|
await this.closeQueue;
|
|
4757
5275
|
if (!this.running) {
|
|
@@ -4766,12 +5284,66 @@ var MobileReplayRecorder = class {
|
|
|
4766
5284
|
return;
|
|
4767
5285
|
}
|
|
4768
5286
|
await this.completeSessionIdentityRotation(nowUnixMs, reason);
|
|
5287
|
+
if (reason === "idle" && this.running) {
|
|
5288
|
+
await this.openSessionAfterIdle();
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
/*
|
|
5292
|
+
* The first things a session after an idle one records: a full snapshot
|
|
5293
|
+
* of the screen the user came back to, then the rotation marker - the
|
|
5294
|
+
* snapshot first, so chunk 0 is a seek anchor rather than a lone marker.
|
|
5295
|
+
*
|
|
5296
|
+
* Taken here, inside the rotation, rather than left to the next capture
|
|
5297
|
+
* tick: until the rotation settles, touches are dropped and every other
|
|
5298
|
+
* event waits for it (rotatingSession), so nothing can land ahead of the
|
|
5299
|
+
* snapshot. A user who has just come back is touching the screen, and
|
|
5300
|
+
* half a second of that was otherwise a chunk 0 with no screen in it.
|
|
5301
|
+
*
|
|
5302
|
+
* In the background or without a registered root there is no screen to
|
|
5303
|
+
* capture yet; the marker opens the session alone.
|
|
5304
|
+
*/
|
|
5305
|
+
async openSessionAfterIdle() {
|
|
5306
|
+
const generation = this.lifecycleGeneration;
|
|
5307
|
+
const rootTag = this.rootTag;
|
|
5308
|
+
const options = this.options;
|
|
5309
|
+
const sessionId = this.identity?.sessionId;
|
|
5310
|
+
if (this.foreground && rootTag && options && sessionId) {
|
|
5311
|
+
const capturedAtUnixMs = this.now();
|
|
5312
|
+
let tree;
|
|
5313
|
+
try {
|
|
5314
|
+
tree = await this.nativeViewTree.captureViewTree(rootTag);
|
|
5315
|
+
} catch (error) {
|
|
5316
|
+
if (this.running && this.isCurrentGeneration(generation)) {
|
|
5317
|
+
this.disable("native-capture-failed", {
|
|
5318
|
+
name: error instanceof Error ? error.name : "Error"
|
|
5319
|
+
});
|
|
5320
|
+
this.haltCapture();
|
|
5321
|
+
}
|
|
5322
|
+
return;
|
|
5323
|
+
}
|
|
5324
|
+
if (!this.running || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== sessionId) {
|
|
5325
|
+
return;
|
|
5326
|
+
}
|
|
5327
|
+
if (this.foreground) {
|
|
5328
|
+
this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
|
|
5329
|
+
this.appendCapture(tree, options, capturedAtUnixMs, true);
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
5332
|
+
this.appendCustom(
|
|
5333
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
5334
|
+
{ reason: "idle" },
|
|
5335
|
+
void 0,
|
|
5336
|
+
true
|
|
5337
|
+
);
|
|
4769
5338
|
}
|
|
4770
5339
|
async completeSessionIdentityRotation(nowUnixMs, reason) {
|
|
4771
5340
|
if (!this.sessionStore || !this.config || !this.options) {
|
|
4772
5341
|
return;
|
|
4773
5342
|
}
|
|
4774
5343
|
this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
|
|
5344
|
+
this.sealedForIdle = false;
|
|
5345
|
+
this.idlePause = null;
|
|
5346
|
+
this.idleResumeBacklog = null;
|
|
4775
5347
|
this.notifySessionChange();
|
|
4776
5348
|
this.sampled = this.identityPersisted ? deterministicSample(
|
|
4777
5349
|
this.identity.sessionId,
|
|
@@ -4792,6 +5364,10 @@ var MobileReplayRecorder = class {
|
|
|
4792
5364
|
if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
|
|
4793
5365
|
this.disable("not-sampled");
|
|
4794
5366
|
this.haltCapture();
|
|
5367
|
+
return;
|
|
5368
|
+
}
|
|
5369
|
+
if (this.captureTimer === null && this.running && this.foreground) {
|
|
5370
|
+
this.startTimers();
|
|
4795
5371
|
}
|
|
4796
5372
|
}
|
|
4797
5373
|
disable(code, details) {
|