@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.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",
|
|
@@ -111,6 +112,9 @@ var IDENTIFY_CUSTOM_EVENT_TAG = "oneuptime.identify";
|
|
|
111
112
|
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";
|
|
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";
|
|
114
118
|
|
|
115
119
|
// src/Sanitize.ts
|
|
116
120
|
var MAX_TRAIT_KEYS = 20;
|
|
@@ -2486,6 +2490,18 @@ var ReplaySessionStore = class {
|
|
|
2486
2490
|
};
|
|
2487
2491
|
return { ...this.identity };
|
|
2488
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
|
+
}
|
|
2489
2505
|
async clear() {
|
|
2490
2506
|
this.identity = null;
|
|
2491
2507
|
await Promise.all([
|
|
@@ -2944,6 +2960,7 @@ function isTouchPrivate(privacyMap, x2, y) {
|
|
|
2944
2960
|
var MAX_DIAGNOSTIC_EVENTS = 100;
|
|
2945
2961
|
var TOUCH_MOVE_SAMPLE_MS = 50;
|
|
2946
2962
|
var MOBILE_POLICY_REFRESH_INTERVAL_MS = 5 * 6e4;
|
|
2963
|
+
var MAX_EVENTS_HELD_FOR_RESUME = 500;
|
|
2947
2964
|
var SAFE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
2948
2965
|
"AggregateError",
|
|
2949
2966
|
"Error",
|
|
@@ -3015,6 +3032,7 @@ var MobileReplayRecorder = class {
|
|
|
3015
3032
|
__publicField(this, "storage");
|
|
3016
3033
|
__publicField(this, "nativeViewTree");
|
|
3017
3034
|
__publicField(this, "appState");
|
|
3035
|
+
__publicField(this, "keyboard");
|
|
3018
3036
|
__publicField(this, "now");
|
|
3019
3037
|
__publicField(this, "serializer", new ViewTreeSerializer());
|
|
3020
3038
|
__publicField(this, "chunkBuffer", new ReplayChunkBuffer());
|
|
@@ -3081,6 +3099,21 @@ var MobileReplayRecorder = class {
|
|
|
3081
3099
|
__publicField(this, "pendingStartCancellation", null);
|
|
3082
3100
|
__publicField(this, "identityPersisted", false);
|
|
3083
3101
|
__publicField(this, "sessionLastActivityUnixMs", 0);
|
|
3102
|
+
/*
|
|
3103
|
+
* The session went the idle window without activity, was sealed where it
|
|
3104
|
+
* ended, and nobody has come back since.
|
|
3105
|
+
*
|
|
3106
|
+
* Rotating on the spot instead (the old behaviour) started a session
|
|
3107
|
+
* nobody was in: an app left open in the foreground with the screen on
|
|
3108
|
+
* re-snapshots every minute, so it filed a new session every half hour
|
|
3109
|
+
* for as long as it stayed open. Now nothing is captured, uploaded or
|
|
3110
|
+
* handed to the host as a session id until the user is back - a touch,
|
|
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.
|
|
3115
|
+
*/
|
|
3116
|
+
__publicField(this, "sealedForIdle", false);
|
|
3084
3117
|
__publicField(this, "rotatingSession", false);
|
|
3085
3118
|
__publicField(this, "rotationPromise", null);
|
|
3086
3119
|
__publicField(this, "appStateQueue", Promise.resolve());
|
|
@@ -3097,9 +3130,58 @@ var MobileReplayRecorder = class {
|
|
|
3097
3130
|
__publicField(this, "suppressedTouchPointers", /* @__PURE__ */ new Set());
|
|
3098
3131
|
__publicField(this, "unidentifiedTouchCount", 0);
|
|
3099
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", []);
|
|
3100
3181
|
this.storage = dependencies.storage ?? defaultReplayStorage;
|
|
3101
3182
|
this.nativeViewTree = dependencies.nativeViewTree ?? defaultNativeViewTreeAdapter;
|
|
3102
3183
|
this.appState = dependencies.appState ?? AppState;
|
|
3184
|
+
this.keyboard = dependencies.keyboard ?? Keyboard ?? null;
|
|
3103
3185
|
this.now = dependencies.now ?? defaultNow;
|
|
3104
3186
|
}
|
|
3105
3187
|
start(options) {
|
|
@@ -3168,6 +3250,7 @@ var MobileReplayRecorder = class {
|
|
|
3168
3250
|
this.appStateQueue = Promise.resolve();
|
|
3169
3251
|
this.rotationPromise = null;
|
|
3170
3252
|
this.rotatingSession = false;
|
|
3253
|
+
this.sealedForIdle = false;
|
|
3171
3254
|
this.policyRefreshPromise = null;
|
|
3172
3255
|
this.lastPolicyRefreshAtUnixMs = 0;
|
|
3173
3256
|
this.forceFullSnapshot = true;
|
|
@@ -3175,6 +3258,8 @@ var MobileReplayRecorder = class {
|
|
|
3175
3258
|
this.touchPrivacyMap = CONSERVATIVE_TOUCH_PRIVACY_MAP;
|
|
3176
3259
|
this.touchQueue = Promise.resolve();
|
|
3177
3260
|
this.resetTouchSequences();
|
|
3261
|
+
this.idlePause = null;
|
|
3262
|
+
this.idleResumeBacklog = null;
|
|
3178
3263
|
const validated = validateStartOptions(options);
|
|
3179
3264
|
if (!validated) {
|
|
3180
3265
|
this.disable("invalid-start-options");
|
|
@@ -3254,6 +3339,7 @@ var MobileReplayRecorder = class {
|
|
|
3254
3339
|
);
|
|
3255
3340
|
}
|
|
3256
3341
|
this.sessionLastActivityUnixMs = nowUnixMs;
|
|
3342
|
+
this.lastInputUnixMs = nowUnixMs;
|
|
3257
3343
|
if (!awaitingConsent && config.captureTrigger === "Always" /* Always */ && !this.sampled && !config.isTargeted) {
|
|
3258
3344
|
this.disable("not-sampled");
|
|
3259
3345
|
return false;
|
|
@@ -3340,12 +3426,23 @@ var MobileReplayRecorder = class {
|
|
|
3340
3426
|
this.diagnosticsStatus = "stopped";
|
|
3341
3427
|
return;
|
|
3342
3428
|
}
|
|
3343
|
-
this.
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3429
|
+
const endedIdle = this.sealedForIdle || this.rotationReason(this.now()) === "idle";
|
|
3430
|
+
const pause = this.idlePause;
|
|
3431
|
+
if (!endedIdle && pause === null) {
|
|
3432
|
+
this.appendCustom(
|
|
3433
|
+
VISIBILITY_CUSTOM_EVENT_TAG,
|
|
3434
|
+
{ state: "stopped" },
|
|
3435
|
+
void 0,
|
|
3436
|
+
true
|
|
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
|
+
);
|
|
3445
|
+
}
|
|
3349
3446
|
this.haltCapture();
|
|
3350
3447
|
await this.closeCurrent(true);
|
|
3351
3448
|
if (this.uploadActive) {
|
|
@@ -3493,7 +3590,7 @@ var MobileReplayRecorder = class {
|
|
|
3493
3590
|
}
|
|
3494
3591
|
async setRoute(route) {
|
|
3495
3592
|
await this.identityQueue;
|
|
3496
|
-
if (this.running && !this.acceptingEvents) {
|
|
3593
|
+
if (this.running && (!this.acceptingEvents || this.isIdlePaused())) {
|
|
3497
3594
|
this.route = sanitizeRoute(route);
|
|
3498
3595
|
return;
|
|
3499
3596
|
}
|
|
@@ -3508,6 +3605,9 @@ var MobileReplayRecorder = class {
|
|
|
3508
3605
|
return;
|
|
3509
3606
|
}
|
|
3510
3607
|
this.route = next;
|
|
3608
|
+
if (this.sealedForIdle) {
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3511
3611
|
this.appendCustom(
|
|
3512
3612
|
ROUTE_CUSTOM_EVENT_TAG,
|
|
3513
3613
|
{
|
|
@@ -3526,7 +3626,15 @@ var MobileReplayRecorder = class {
|
|
|
3526
3626
|
if (!this.running || !this.acceptingEvents) {
|
|
3527
3627
|
return;
|
|
3528
3628
|
}
|
|
3529
|
-
|
|
3629
|
+
const resume = this.foreground ? this.endIdlePause(this.now()) : "none";
|
|
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
|
+
}
|
|
3530
3638
|
const safeReason2 = truncate(
|
|
3531
3639
|
(typeof reason === "string" ? reason.trim() : "") || "manual",
|
|
3532
3640
|
MAX_CAPTURE_REASON_LENGTH
|
|
@@ -3542,7 +3650,13 @@ var MobileReplayRecorder = class {
|
|
|
3542
3650
|
if (!this.running || !this.acceptingEvents) {
|
|
3543
3651
|
return;
|
|
3544
3652
|
}
|
|
3653
|
+
if (this.isIdlePaused()) {
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3545
3656
|
await this.maybeRotateSession(this.now());
|
|
3657
|
+
if (this.sealedForIdle) {
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3546
3660
|
const rawName = error instanceof Error && typeof error.name === "string" ? error.name.trim() : "";
|
|
3547
3661
|
const name = SAFE_ERROR_NAMES.has(rawName) ? rawName : "Error";
|
|
3548
3662
|
this.appendCustom(
|
|
@@ -3564,9 +3678,18 @@ var MobileReplayRecorder = class {
|
|
|
3564
3678
|
if (!this.running || !this.acceptingEvents || !this.foreground) {
|
|
3565
3679
|
return;
|
|
3566
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
|
+
}
|
|
3567
3687
|
const generation = this.lifecycleGeneration;
|
|
3568
3688
|
const operation = this.touchQueue.then(
|
|
3569
3689
|
async () => {
|
|
3690
|
+
if (resumeCapture) {
|
|
3691
|
+
await resumeCapture;
|
|
3692
|
+
}
|
|
3570
3693
|
await this.recordTouchAfterPrivacyCheck(sanitizedTouch, generation);
|
|
3571
3694
|
}
|
|
3572
3695
|
);
|
|
@@ -3593,9 +3716,9 @@ var MobileReplayRecorder = class {
|
|
|
3593
3716
|
this.lastTouchMoveAtUnixMs = touch.timestamp;
|
|
3594
3717
|
}
|
|
3595
3718
|
const nowUnixMs = this.now();
|
|
3596
|
-
if (this.rotatingSession || this.rotationReason(nowUnixMs)) {
|
|
3719
|
+
if (this.rotatingSession || this.sealedForIdle || this.rotationReason(nowUnixMs)) {
|
|
3597
3720
|
this.diagnostic("touch-dropped-session-rotation");
|
|
3598
|
-
void this.
|
|
3721
|
+
void this.maybeRotateForUser(nowUnixMs);
|
|
3599
3722
|
return;
|
|
3600
3723
|
}
|
|
3601
3724
|
this.appendEvent(
|
|
@@ -3620,8 +3743,9 @@ var MobileReplayRecorder = class {
|
|
|
3620
3743
|
}
|
|
3621
3744
|
}
|
|
3622
3745
|
getDiagnostics() {
|
|
3746
|
+
const status = this.diagnosticsStatus === "recording" && this.running && this.foreground && (this.isIdlePaused() || this.sealedForIdle) ? "paused" : this.diagnosticsStatus;
|
|
3623
3747
|
return {
|
|
3624
|
-
status
|
|
3748
|
+
status,
|
|
3625
3749
|
sessionId: this.identity?.sessionId ?? null,
|
|
3626
3750
|
recorderKind: MOBILE_RECORDER_KIND,
|
|
3627
3751
|
configEpoch: this.config?.configEpoch ?? 0,
|
|
@@ -3643,9 +3767,14 @@ var MobileReplayRecorder = class {
|
|
|
3643
3767
|
)
|
|
3644
3768
|
};
|
|
3645
3769
|
}
|
|
3646
|
-
/**
|
|
3770
|
+
/**
|
|
3771
|
+
* Current consented replay session for host trace/log correlation. None
|
|
3772
|
+
* while the session has ended idle and nobody is back: a background poll
|
|
3773
|
+
* on an idle app is no part of a recording that ended at the user's last
|
|
3774
|
+
* activity.
|
|
3775
|
+
*/
|
|
3647
3776
|
getSessionId() {
|
|
3648
|
-
return this.running && this.identityPersisted ? this.identity?.sessionId ?? null : null;
|
|
3777
|
+
return this.running && this.identityPersisted && !this.sealedForIdle ? this.identity?.sessionId ?? null : null;
|
|
3649
3778
|
}
|
|
3650
3779
|
/** Fires immediately when a session exists and again on rotate/clear. */
|
|
3651
3780
|
onSessionChange(listener) {
|
|
@@ -3698,9 +3827,16 @@ var MobileReplayRecorder = class {
|
|
|
3698
3827
|
}
|
|
3699
3828
|
await this.transport?.restore();
|
|
3700
3829
|
}
|
|
3701
|
-
|
|
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) {
|
|
3702
3835
|
const generation = this.lifecycleGeneration;
|
|
3703
|
-
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.captureGeneration === generation || !this.options) {
|
|
3836
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.rootTag || this.sealedForIdle || this.captureGeneration === generation || !this.options) {
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
if (this.isIdlePaused()) {
|
|
3704
3840
|
return;
|
|
3705
3841
|
}
|
|
3706
3842
|
this.captureGeneration = generation;
|
|
@@ -3715,7 +3851,10 @@ var MobileReplayRecorder = class {
|
|
|
3715
3851
|
return;
|
|
3716
3852
|
}
|
|
3717
3853
|
await this.maybeRotateSession(nowUnixMs);
|
|
3718
|
-
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation)) {
|
|
3854
|
+
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.sealedForIdle) {
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
if (this.isIdlePaused() || this.idlePause !== null && this.rotationReason(nowUnixMs) !== null) {
|
|
3719
3858
|
return;
|
|
3720
3859
|
}
|
|
3721
3860
|
const capturedSessionId = this.identity?.sessionId;
|
|
@@ -3724,7 +3863,6 @@ var MobileReplayRecorder = class {
|
|
|
3724
3863
|
return;
|
|
3725
3864
|
}
|
|
3726
3865
|
this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
|
|
3727
|
-
const size = getViewport();
|
|
3728
3866
|
const shouldForce = force || this.forceFullSnapshot || nowUnixMs - this.lastFullSnapshotAtUnixMs >= SESSION_REPLAY_CHECKOUT_INTERVAL_MS;
|
|
3729
3867
|
if (shouldForce && !this.chunkBuffer.isEmpty()) {
|
|
3730
3868
|
await this.closeCurrent(false);
|
|
@@ -3732,25 +3870,13 @@ var MobileReplayRecorder = class {
|
|
|
3732
3870
|
if (!this.running || !this.acceptingEvents || !this.foreground || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== capturedSessionId) {
|
|
3733
3871
|
return;
|
|
3734
3872
|
}
|
|
3735
|
-
|
|
3736
|
-
timestamp: nowUnixMs,
|
|
3737
|
-
url: toAppUrl(this.options.mobileAppIdentifier, this.route),
|
|
3738
|
-
viewportWidth: size.width,
|
|
3739
|
-
viewportHeight: size.height,
|
|
3740
|
-
forceFullSnapshot: shouldForce
|
|
3741
|
-
});
|
|
3742
|
-
this.droppedEvents += capture.droppedNodes;
|
|
3743
|
-
this.chunkBuffer.appendMany(capture.events, {
|
|
3744
|
-
route: toAppUrl(this.options.mobileAppIdentifier, this.route),
|
|
3745
|
-
fidelityNotices: capture.fidelityNotices
|
|
3746
|
-
});
|
|
3747
|
-
if (capture.hasFullSnapshot) {
|
|
3748
|
-
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
3749
|
-
this.forceFullSnapshot = false;
|
|
3750
|
-
}
|
|
3873
|
+
this.appendCapture(tree, this.options, nowUnixMs, shouldForce);
|
|
3751
3874
|
if (this.chunkBuffer.shouldFlush()) {
|
|
3752
3875
|
await this.closeCurrent(false);
|
|
3753
3876
|
}
|
|
3877
|
+
if (mayPauseForIdle && !this.softKeyboardVisible && nowUnixMs - this.lastInputUnixMs >= SESSION_REPLAY_IDLE_PAUSE_MS) {
|
|
3878
|
+
await this.pauseForIdle(nowUnixMs, generation);
|
|
3879
|
+
}
|
|
3754
3880
|
} catch (error) {
|
|
3755
3881
|
if (this.running && this.isCurrentGeneration(generation)) {
|
|
3756
3882
|
this.disable("native-capture-failed", {
|
|
@@ -3764,6 +3890,183 @@ var MobileReplayRecorder = class {
|
|
|
3764
3890
|
}
|
|
3765
3891
|
}
|
|
3766
3892
|
}
|
|
3893
|
+
appendCapture(tree, options, nowUnixMs, forceFullSnapshot) {
|
|
3894
|
+
const size = getViewport();
|
|
3895
|
+
const url = toAppUrl(options.mobileAppIdentifier, this.route);
|
|
3896
|
+
const capture = this.serializer.capture(tree, {
|
|
3897
|
+
timestamp: nowUnixMs,
|
|
3898
|
+
url,
|
|
3899
|
+
viewportWidth: size.width,
|
|
3900
|
+
viewportHeight: size.height,
|
|
3901
|
+
forceFullSnapshot
|
|
3902
|
+
});
|
|
3903
|
+
this.droppedEvents += capture.droppedNodes;
|
|
3904
|
+
this.chunkBuffer.appendMany(capture.events, {
|
|
3905
|
+
route: url,
|
|
3906
|
+
fidelityNotices: capture.fidelityNotices
|
|
3907
|
+
});
|
|
3908
|
+
if (capture.hasFullSnapshot) {
|
|
3909
|
+
this.lastFullSnapshotAtUnixMs = nowUnixMs;
|
|
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
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
3767
4070
|
appendCustom(tag, payload, signal, allowWhenQuiesced = false) {
|
|
3768
4071
|
this.appendEvent(
|
|
3769
4072
|
createCustomEvent(tag, payload, this.now()),
|
|
@@ -3775,15 +4078,17 @@ var MobileReplayRecorder = class {
|
|
|
3775
4078
|
if (!this.running || !this.acceptingEvents && !allowWhenQuiesced) {
|
|
3776
4079
|
return;
|
|
3777
4080
|
}
|
|
4081
|
+
if (this.sealedForIdle) {
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
3778
4084
|
if (!Number.isFinite(event.timestamp)) {
|
|
3779
4085
|
this.droppedEvents += 1;
|
|
3780
4086
|
this.diagnostic("invalid-event-timestamp");
|
|
3781
4087
|
return;
|
|
3782
4088
|
}
|
|
3783
|
-
this.
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
);
|
|
4089
|
+
if (this.isIdlePaused()) {
|
|
4090
|
+
return;
|
|
4091
|
+
}
|
|
3787
4092
|
const appendOptions = {};
|
|
3788
4093
|
if (this.options) {
|
|
3789
4094
|
appendOptions.route = toAppUrl(
|
|
@@ -3794,6 +4099,18 @@ var MobileReplayRecorder = class {
|
|
|
3794
4099
|
if (signal) {
|
|
3795
4100
|
appendOptions.signal = signal;
|
|
3796
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
|
+
);
|
|
3797
4114
|
const accepted = this.chunkBuffer.append(event, appendOptions);
|
|
3798
4115
|
if (!accepted) {
|
|
3799
4116
|
this.droppedEvents += 1;
|
|
@@ -3803,7 +4120,7 @@ var MobileReplayRecorder = class {
|
|
|
3803
4120
|
}
|
|
3804
4121
|
}
|
|
3805
4122
|
async closeCurrent(isFinal) {
|
|
3806
|
-
const closesFinalChunkSlot = !isFinal && !this.rotatingSession && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
|
|
4123
|
+
const closesFinalChunkSlot = !isFinal && !this.rotatingSession && !this.sealedForIdle && this.uploadActive && this.sessionStore?.getNextChunkIndex() === MAX_SESSION_REPLAY_CHUNKS_PER_SESSION - 1;
|
|
3807
4124
|
if (closesFinalChunkSlot) {
|
|
3808
4125
|
this.rotatingSession = true;
|
|
3809
4126
|
const rotationOptions = {};
|
|
@@ -3815,7 +4132,7 @@ var MobileReplayRecorder = class {
|
|
|
3815
4132
|
}
|
|
3816
4133
|
this.chunkBuffer.append(
|
|
3817
4134
|
createCustomEvent(
|
|
3818
|
-
|
|
4135
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
3819
4136
|
{ reason: "chunk-cap" },
|
|
3820
4137
|
this.now()
|
|
3821
4138
|
),
|
|
@@ -3979,6 +4296,63 @@ var MobileReplayRecorder = class {
|
|
|
3979
4296
|
});
|
|
3980
4297
|
}
|
|
3981
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
|
+
}
|
|
3982
4356
|
}
|
|
3983
4357
|
async onAppStateChange(state, generation) {
|
|
3984
4358
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
@@ -3998,6 +4372,13 @@ var MobileReplayRecorder = class {
|
|
|
3998
4372
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
3999
4373
|
return;
|
|
4000
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
|
+
}
|
|
4001
4382
|
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "hidden" });
|
|
4002
4383
|
await this.closeCurrent(false);
|
|
4003
4384
|
if (this.uploadActive) {
|
|
@@ -4007,6 +4388,7 @@ var MobileReplayRecorder = class {
|
|
|
4007
4388
|
}
|
|
4008
4389
|
if (isActive && !this.foreground) {
|
|
4009
4390
|
this.foreground = true;
|
|
4391
|
+
const input = this.noteUserInput(this.now());
|
|
4010
4392
|
const policyReady = await this.refreshRuntimePolicy(
|
|
4011
4393
|
generation,
|
|
4012
4394
|
true,
|
|
@@ -4015,15 +4397,22 @@ var MobileReplayRecorder = class {
|
|
|
4015
4397
|
if (!policyReady) {
|
|
4016
4398
|
return;
|
|
4017
4399
|
}
|
|
4018
|
-
|
|
4400
|
+
const sessionIdBeforeResume = this.identity?.sessionId;
|
|
4401
|
+
await this.maybeRotateSession(this.now(), true);
|
|
4019
4402
|
if (!this.running || !this.isCurrentGeneration(generation)) {
|
|
4020
4403
|
return;
|
|
4021
4404
|
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4405
|
+
const resumedPausedSession = input === "resumed" && this.identity?.sessionId === sessionIdBeforeResume;
|
|
4406
|
+
if (!resumedPausedSession) {
|
|
4407
|
+
this.appendCustom(VISIBILITY_CUSTOM_EVENT_TAG, { state: "visible" });
|
|
4408
|
+
}
|
|
4409
|
+
const openedOnSnapshot = this.identity?.sessionId !== sessionIdBeforeResume && this.lastFullSnapshotAtUnixMs > 0;
|
|
4410
|
+
if (!openedOnSnapshot) {
|
|
4411
|
+
this.forceFullSnapshot = true;
|
|
4412
|
+
}
|
|
4024
4413
|
this.startTimers();
|
|
4025
4414
|
this.diagnosticsStatus = this.consentState() === "Unknown" ? "consent-required" : "recording";
|
|
4026
|
-
await this.captureTick(
|
|
4415
|
+
await this.captureTick(!openedOnSnapshot);
|
|
4027
4416
|
if (this.uploadActive && this.running) {
|
|
4028
4417
|
this.transport?.resume();
|
|
4029
4418
|
}
|
|
@@ -4042,16 +4431,31 @@ var MobileReplayRecorder = class {
|
|
|
4042
4431
|
void this.captureTick();
|
|
4043
4432
|
}, intervalMs);
|
|
4044
4433
|
this.flushTimer = setInterval(() => {
|
|
4434
|
+
if (this.isIdlePaused()) {
|
|
4435
|
+
if (this.acceptingEvents) {
|
|
4436
|
+
void this.maybeRotateSession(this.now());
|
|
4437
|
+
}
|
|
4438
|
+
return;
|
|
4439
|
+
}
|
|
4045
4440
|
if (this.acceptingEvents) {
|
|
4046
4441
|
void this.closeCurrent(false);
|
|
4047
4442
|
}
|
|
4048
4443
|
}, SESSION_REPLAY_FLUSH_INTERVAL_MS);
|
|
4049
4444
|
}
|
|
4050
|
-
|
|
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() {
|
|
4051
4452
|
if (this.captureTimer) {
|
|
4052
4453
|
clearInterval(this.captureTimer);
|
|
4053
4454
|
this.captureTimer = null;
|
|
4054
4455
|
}
|
|
4456
|
+
}
|
|
4457
|
+
clearTimers() {
|
|
4458
|
+
this.stopCaptureTimer();
|
|
4055
4459
|
if (this.flushTimer) {
|
|
4056
4460
|
clearInterval(this.flushTimer);
|
|
4057
4461
|
this.flushTimer = null;
|
|
@@ -4143,6 +4547,7 @@ var MobileReplayRecorder = class {
|
|
|
4143
4547
|
this.clearTimers();
|
|
4144
4548
|
this.appStateSubscription?.remove();
|
|
4145
4549
|
this.appStateSubscription = null;
|
|
4550
|
+
this.removeKeyboardListeners();
|
|
4146
4551
|
this.stopConnectivity();
|
|
4147
4552
|
this.restoreGlobalErrorHandler();
|
|
4148
4553
|
}
|
|
@@ -4303,9 +4708,16 @@ var MobileReplayRecorder = class {
|
|
|
4303
4708
|
if (!this.running || identityEpoch !== this.identityCancellationEpoch) {
|
|
4304
4709
|
return;
|
|
4305
4710
|
}
|
|
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
|
+
}
|
|
4717
|
+
}
|
|
4306
4718
|
if (this.rotationPromise) {
|
|
4307
4719
|
await this.rotationPromise;
|
|
4308
|
-
} else {
|
|
4720
|
+
} else if (!this.sealedForIdle) {
|
|
4309
4721
|
this.rotatingSession = true;
|
|
4310
4722
|
const rotation = this.performSessionRotation(
|
|
4311
4723
|
this.now(),
|
|
@@ -4640,16 +5052,24 @@ var MobileReplayRecorder = class {
|
|
|
4640
5052
|
chunkIndex: 0
|
|
4641
5053
|
};
|
|
4642
5054
|
}
|
|
5055
|
+
/*
|
|
5056
|
+
* Idle before the duration cap, the order Common's SessionIdentity decides
|
|
5057
|
+
* them in. A session left for the idle window ended where it was left,
|
|
5058
|
+
* even when it has outlived the cap by the time anything notices, and
|
|
5059
|
+
* only an idle rotation dates its end there (see performSessionRotation):
|
|
5060
|
+
* called "duration", a session three hours old put in the background for
|
|
5061
|
+
* two more was sealed two hours after its footage.
|
|
5062
|
+
*/
|
|
4643
5063
|
rotationReason(nowUnixMs) {
|
|
4644
5064
|
if (!this.identity) {
|
|
4645
5065
|
return null;
|
|
4646
5066
|
}
|
|
4647
|
-
if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
|
|
4648
|
-
return "duration";
|
|
4649
|
-
}
|
|
4650
5067
|
if (nowUnixMs - this.sessionLastActivityUnixMs >= SESSION_REPLAY_IDLE_ROLLOVER_MS) {
|
|
4651
5068
|
return "idle";
|
|
4652
5069
|
}
|
|
5070
|
+
if (nowUnixMs - this.identity.sessionStartUnixMs >= SESSION_REPLAY_MAX_SESSION_MS) {
|
|
5071
|
+
return "duration";
|
|
5072
|
+
}
|
|
4653
5073
|
return null;
|
|
4654
5074
|
}
|
|
4655
5075
|
runExternalActivity(nowUnixMs, action) {
|
|
@@ -4684,23 +5104,61 @@ var MobileReplayRecorder = class {
|
|
|
4684
5104
|
}
|
|
4685
5105
|
});
|
|
4686
5106
|
}
|
|
4687
|
-
|
|
4688
|
-
|
|
5107
|
+
/*
|
|
5108
|
+
* isUserBack: the caller knows the user is here - a touch, the app
|
|
5109
|
+
* returning to the foreground, an explicit captureSession(). An idle
|
|
5110
|
+
* session then rotates at once. Anything else (a capture tick, a route
|
|
5111
|
+
* change, a developer event) only finds out the session went idle, and
|
|
5112
|
+
* seals it to wait for them (see sealedForIdle).
|
|
5113
|
+
*/
|
|
5114
|
+
async maybeRotateSession(nowUnixMs, isUserBack = false) {
|
|
5115
|
+
while (this.rotationPromise) {
|
|
4689
5116
|
await this.rotationPromise;
|
|
4690
|
-
|
|
5117
|
+
if (!isUserBack || !this.sealedForIdle) {
|
|
5118
|
+
return;
|
|
5119
|
+
}
|
|
4691
5120
|
}
|
|
4692
5121
|
if (!this.running || this.rotatingSession || !this.identity || !this.config || !this.sessionStore || !this.options) {
|
|
4693
5122
|
return;
|
|
4694
5123
|
}
|
|
4695
|
-
|
|
5124
|
+
let reason;
|
|
5125
|
+
if (this.sealedForIdle) {
|
|
5126
|
+
if (!isUserBack) {
|
|
5127
|
+
return;
|
|
5128
|
+
}
|
|
5129
|
+
reason = "idle";
|
|
5130
|
+
} else {
|
|
5131
|
+
reason = this.rotationReason(nowUnixMs);
|
|
5132
|
+
if (reason !== null && this.isIdlePaused()) {
|
|
5133
|
+
reason = "idle";
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
4696
5136
|
if (!reason) {
|
|
4697
5137
|
return;
|
|
4698
5138
|
}
|
|
4699
5139
|
this.rotatingSession = true;
|
|
4700
|
-
const operation = this.performSessionRotation(
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
5140
|
+
const operation = reason === "idle" && !isUserBack ? this.sealForIdle() : this.performSessionRotation(nowUnixMs, reason);
|
|
5141
|
+
this.rotationPromise = operation;
|
|
5142
|
+
try {
|
|
5143
|
+
await operation;
|
|
5144
|
+
} finally {
|
|
5145
|
+
if (this.rotationPromise === operation) {
|
|
5146
|
+
this.rotationPromise = null;
|
|
5147
|
+
}
|
|
5148
|
+
this.rotatingSession = false;
|
|
5149
|
+
}
|
|
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();
|
|
4704
5162
|
this.rotationPromise = operation;
|
|
4705
5163
|
try {
|
|
4706
5164
|
await operation;
|
|
@@ -4711,8 +5169,68 @@ var MobileReplayRecorder = class {
|
|
|
4711
5169
|
this.rotatingSession = false;
|
|
4712
5170
|
}
|
|
4713
5171
|
}
|
|
5172
|
+
/*
|
|
5173
|
+
* maybeRotateSession for a user who is here. On an app whose session
|
|
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
|
|
5176
|
+
* refreshed it while the app sat idle, and the next session must not open
|
|
5177
|
+
* under one that has since changed (or turned replay off).
|
|
5178
|
+
*/
|
|
5179
|
+
async maybeRotateForUser(nowUnixMs) {
|
|
5180
|
+
if ((this.sealedForIdle || this.idlePause !== null) && !this.rotatingSession) {
|
|
5181
|
+
const policyReady = await this.refreshRuntimePolicy(
|
|
5182
|
+
this.lifecycleGeneration,
|
|
5183
|
+
false,
|
|
5184
|
+
"periodic"
|
|
5185
|
+
);
|
|
5186
|
+
if (!policyReady) {
|
|
5187
|
+
return;
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
await this.maybeRotateSession(nowUnixMs, true);
|
|
5191
|
+
}
|
|
5192
|
+
/*
|
|
5193
|
+
* End the session as idle, where it really ended, and wait for the user.
|
|
5194
|
+
*
|
|
5195
|
+
* Sealed with whatever footage is still buffered - dated by that footage,
|
|
5196
|
+
* never by now - and with no marker of its own: a marker stamped now
|
|
5197
|
+
* would date the session's end half an hour or more after anything the
|
|
5198
|
+
* player can show. With nothing buffered nothing is sent, and the server
|
|
5199
|
+
* ends the session at its last chunk - for a session paused for idle
|
|
5200
|
+
* (#4208), the chunk its idle-paused marker closed.
|
|
5201
|
+
*/
|
|
5202
|
+
async sealForIdle() {
|
|
5203
|
+
this.sealedForIdle = true;
|
|
5204
|
+
this.idlePause = null;
|
|
5205
|
+
this.idleResumeBacklog = null;
|
|
5206
|
+
this.clearTimers();
|
|
5207
|
+
this.diagnostic("session-ended-idle", {
|
|
5208
|
+
idleForMs: Math.max(0, this.now() - this.sessionLastActivityUnixMs)
|
|
5209
|
+
});
|
|
5210
|
+
this.notifySessionChange();
|
|
5211
|
+
await this.closeCurrent(true);
|
|
5212
|
+
await this.closeQueue;
|
|
5213
|
+
if (!this.running) {
|
|
5214
|
+
return;
|
|
5215
|
+
}
|
|
5216
|
+
if (this.uploadActive) {
|
|
5217
|
+
await this.transport?.drain();
|
|
5218
|
+
} else {
|
|
5219
|
+
this.rollingBuffer.clear();
|
|
5220
|
+
}
|
|
5221
|
+
if (this.running && this.identityPersisted) {
|
|
5222
|
+
await this.sessionStore?.end();
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
4714
5225
|
async performSessionRotation(nowUnixMs, reason) {
|
|
4715
|
-
|
|
5226
|
+
if (reason !== "idle") {
|
|
5227
|
+
this.appendCustom(
|
|
5228
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
5229
|
+
{ reason },
|
|
5230
|
+
void 0,
|
|
5231
|
+
true
|
|
5232
|
+
);
|
|
5233
|
+
}
|
|
4716
5234
|
await this.closeCurrent(true);
|
|
4717
5235
|
await this.closeQueue;
|
|
4718
5236
|
if (!this.running) {
|
|
@@ -4727,12 +5245,66 @@ var MobileReplayRecorder = class {
|
|
|
4727
5245
|
return;
|
|
4728
5246
|
}
|
|
4729
5247
|
await this.completeSessionIdentityRotation(nowUnixMs, reason);
|
|
5248
|
+
if (reason === "idle" && this.running) {
|
|
5249
|
+
await this.openSessionAfterIdle();
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
5252
|
+
/*
|
|
5253
|
+
* The first things a session after an idle one records: a full snapshot
|
|
5254
|
+
* of the screen the user came back to, then the rotation marker - the
|
|
5255
|
+
* snapshot first, so chunk 0 is a seek anchor rather than a lone marker.
|
|
5256
|
+
*
|
|
5257
|
+
* Taken here, inside the rotation, rather than left to the next capture
|
|
5258
|
+
* tick: until the rotation settles, touches are dropped and every other
|
|
5259
|
+
* event waits for it (rotatingSession), so nothing can land ahead of the
|
|
5260
|
+
* snapshot. A user who has just come back is touching the screen, and
|
|
5261
|
+
* half a second of that was otherwise a chunk 0 with no screen in it.
|
|
5262
|
+
*
|
|
5263
|
+
* In the background or without a registered root there is no screen to
|
|
5264
|
+
* capture yet; the marker opens the session alone.
|
|
5265
|
+
*/
|
|
5266
|
+
async openSessionAfterIdle() {
|
|
5267
|
+
const generation = this.lifecycleGeneration;
|
|
5268
|
+
const rootTag = this.rootTag;
|
|
5269
|
+
const options = this.options;
|
|
5270
|
+
const sessionId = this.identity?.sessionId;
|
|
5271
|
+
if (this.foreground && rootTag && options && sessionId) {
|
|
5272
|
+
const capturedAtUnixMs = this.now();
|
|
5273
|
+
let tree;
|
|
5274
|
+
try {
|
|
5275
|
+
tree = await this.nativeViewTree.captureViewTree(rootTag);
|
|
5276
|
+
} catch (error) {
|
|
5277
|
+
if (this.running && this.isCurrentGeneration(generation)) {
|
|
5278
|
+
this.disable("native-capture-failed", {
|
|
5279
|
+
name: error instanceof Error ? error.name : "Error"
|
|
5280
|
+
});
|
|
5281
|
+
this.haltCapture();
|
|
5282
|
+
}
|
|
5283
|
+
return;
|
|
5284
|
+
}
|
|
5285
|
+
if (!this.running || !this.isCurrentGeneration(generation) || this.identity?.sessionId !== sessionId) {
|
|
5286
|
+
return;
|
|
5287
|
+
}
|
|
5288
|
+
if (this.foreground) {
|
|
5289
|
+
this.touchPrivacyMap = deriveTouchPrivacyMap(tree);
|
|
5290
|
+
this.appendCapture(tree, options, capturedAtUnixMs, true);
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
this.appendCustom(
|
|
5294
|
+
SESSION_ROTATED_CUSTOM_EVENT_TAG,
|
|
5295
|
+
{ reason: "idle" },
|
|
5296
|
+
void 0,
|
|
5297
|
+
true
|
|
5298
|
+
);
|
|
4730
5299
|
}
|
|
4731
5300
|
async completeSessionIdentityRotation(nowUnixMs, reason) {
|
|
4732
5301
|
if (!this.sessionStore || !this.config || !this.options) {
|
|
4733
5302
|
return;
|
|
4734
5303
|
}
|
|
4735
5304
|
this.identity = this.identityPersisted ? await this.sessionStore.rotate(nowUnixMs) : this.ephemeralIdentity(nowUnixMs);
|
|
5305
|
+
this.sealedForIdle = false;
|
|
5306
|
+
this.idlePause = null;
|
|
5307
|
+
this.idleResumeBacklog = null;
|
|
4736
5308
|
this.notifySessionChange();
|
|
4737
5309
|
this.sampled = this.identityPersisted ? deterministicSample(
|
|
4738
5310
|
this.identity.sessionId,
|
|
@@ -4753,6 +5325,10 @@ var MobileReplayRecorder = class {
|
|
|
4753
5325
|
if (this.config.captureTrigger === "Always" /* Always */ && this.identityPersisted && !this.sampled && !this.config.isTargeted) {
|
|
4754
5326
|
this.disable("not-sampled");
|
|
4755
5327
|
this.haltCapture();
|
|
5328
|
+
return;
|
|
5329
|
+
}
|
|
5330
|
+
if (this.captureTimer === null && this.running && this.foreground) {
|
|
5331
|
+
this.startTimers();
|
|
4756
5332
|
}
|
|
4757
5333
|
}
|
|
4758
5334
|
disable(code, details) {
|