Skip to content

Commit ffc647c

Browse files
fix: Prevent double import of Session Replay aggregate class (#1558)
1 parent 66ee465 commit ffc647c

File tree

9 files changed

+136
-97
lines changed

9 files changed

+136
-97
lines changed

src/common/constants/agent-constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
export const IDEAL_PAYLOAD_SIZE = 16000
66
export const MAX_PAYLOAD_SIZE = 1000000
77
export const DEFAULT_KEY = 'NR_CONTAINER_AGENT'
8+
export const SESSION_ERROR = 'SESSION_ERROR'

src/features/session_replay/aggregate/index.js

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ export class Aggregate extends AggregateBase {
4343
/** set at BCS response, stored in runtime */
4444
this.timeKeeper = undefined
4545

46-
this.recorder = args?.recorder
47-
this.errorNoticed = args?.errorNoticed || false
46+
this.instrumentClass = args
47+
// point this var here just in case it already exists and can be used by APIs (session pause, resume, etc.) before handling rum flags
48+
this.recorder = this.instrumentClass?.recorder
49+
4850
this.harvestOpts.raw = true
4951

5052
this.isSessionTrackingEnabled = canEnableSessionTracking(agentRef.init) && !!agentRef.runtime.session
@@ -64,7 +66,7 @@ export class Aggregate extends AggregateBase {
6466
// if the mode changed on a different tab, it needs to update this instance to match
6567
this.mode = agentRef.runtime.session.state.sessionReplayMode
6668
if (!this.initialized || this.mode === MODE.OFF) return
67-
this.recorder?.startRecording()
69+
this.recorder?.startRecording(TRIGGERS.RESUME, this.mode)
6870
})
6971

7072
this.ee.on(SESSION_EVENTS.UPDATE, (type, data) => {
@@ -131,7 +133,7 @@ export class Aggregate extends AggregateBase {
131133
this.mode = MODE.FULL
132134
// if the error was noticed AFTER the recorder was already imported....
133135
if (this.recorder && this.initialized) {
134-
if (!this.agentRef.runtime.isRecording) this.recorder.startRecording()
136+
if (!this.agentRef.runtime.isRecording) this.recorder.startRecording(TRIGGERS.SWITCH_TO_FULL, this.mode) // off --> full
135137
this.syncWithSessionManager({ sessionReplayMode: this.mode })
136138
} else {
137139
this.initializeRecording(MODE.FULL, true)
@@ -142,9 +144,10 @@ export class Aggregate extends AggregateBase {
142144
* Evaluate entitlements and sampling before starting feature mechanics, importing and configuring recording library, and setting storage state
143145
* @param {boolean} srMode - the true/false state of the "sr" flag (aka. entitlements) from RUM response
144146
* @param {boolean} ignoreSession - whether to force the method to ignore the session state and use just the sample flags
147+
* @param {TRIGGERS} [trigger=TRIGGERS.INITIALIZE] - the trigger that initiated the recording. Usually TRIGGERS.INITIALIZE, but could be TRIGGERS.API since in certain cases that trigger calls this method
145148
* @returns {void}
146149
*/
147-
async initializeRecording (srMode, ignoreSession) {
150+
async initializeRecording (srMode, ignoreSession, trigger = TRIGGERS.INITIALIZE) {
148151
this.initialized = true
149152
if (!this.entitled) return
150153

@@ -156,7 +159,8 @@ export class Aggregate extends AggregateBase {
156159
// session replays can continue if already in progress
157160
const { session, timeKeeper } = this.agentRef.runtime
158161
this.timeKeeper = timeKeeper
159-
if (this.recorder?.parent.trigger === TRIGGERS.API && this.agentRef.runtime.isRecording) {
162+
163+
if (this.recorder?.trigger === TRIGGERS.API && this.agentRef.runtime.isRecording) {
160164
this.mode = MODE.FULL
161165
} else if (!session.isNew && !ignoreSession) { // inherit the mode of the existing session
162166
this.mode = session.state.sessionReplayMode
@@ -167,29 +171,24 @@ export class Aggregate extends AggregateBase {
167171
// If off, then don't record (early return)
168172
if (this.mode === MODE.OFF) return
169173

170-
if (!this.recorder) {
171-
try {
172-
// Do not change the webpackChunkName or it will break the webpack nrba-chunking plugin
173-
const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
174-
this.recorder = new Recorder(this)
175-
this.recorder.events.hasError = this.errorNoticed
176-
} catch (err) {
177-
return this.abort(ABORT_REASONS.IMPORT)
178-
}
179-
} else {
180-
this.recorder.parent = this
174+
try {
175+
/** will return a recorder instance if already imported, otherwise, will fetch the recorder and initialize it */
176+
this.recorder ??= await this.instrumentClass.importRecorder()
177+
} catch (err) {
178+
/** if the recorder fails to import, abort the feature */
179+
return this.abort(ABORT_REASONS.IMPORT, err)
181180
}
182181

183182
// If an error was noticed before the mode could be set (like in the early lifecycle of the page), immediately set to FULL mode
184-
if (this.mode === MODE.ERROR && this.errorNoticed) this.mode = MODE.FULL
183+
if (this.mode === MODE.ERROR && this.instrumentClass.errorNoticed) { this.mode = MODE.FULL }
185184

186185
// FULL mode records AND reports from the beginning, while ERROR mode only records (but does not report).
187186
// ERROR mode will do this until an error is thrown, and then switch into FULL mode.
188187
// The makeHarvestPayload should ensure that no payload is returned if we're not in FULL mode...
189188

190189
await this.prepUtils()
191190

192-
if (!this.agentRef.runtime.isRecording) this.recorder.startRecording()
191+
if (!this.agentRef.runtime.isRecording) this.recorder.startRecording(trigger, this.mode)
193192

194193
this.syncWithSessionManager({ sessionReplayMode: this.mode })
195194
}

src/features/session_replay/constants.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,9 @@ export const ABORT_REASONS = {
5454
export const QUERY_PARAM_PADDING = 5000
5555

5656
export const TRIGGERS = {
57-
API: 'api'
57+
API: 'api',
58+
RESUME: 'resume',
59+
SWITCH_TO_FULL: 'switchToFull',
60+
INITIALIZE: 'initialize',
61+
PRELOAD: 'preload'
5862
}

src/features/session_replay/instrument/index.js

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import { setupPauseReplayAPI } from '../../../loaders/api/pauseReplay'
1616

1717
export class Instrument extends InstrumentBase {
1818
static featureName = FEATURE_NAME
19+
/** @type {Promise|undefined} A promise that resolves when the recorder module is imported and added to the class. Undefined if the recorder has never been staged to import with `importRecorder`. */
20+
#stagedImport
21+
/** The RRWEB recorder instance, if imported */
22+
recorder
1923

20-
#mode
21-
#agentRef
2224
constructor (agentRef) {
2325
super(agentRef, FEATURE_NAME)
2426

@@ -27,7 +29,6 @@ export class Instrument extends InstrumentBase {
2729
setupPauseReplayAPI(agentRef)
2830

2931
let session
30-
this.#agentRef = agentRef
3132
try {
3233
session = JSON.parse(localStorage.getItem(`${PREFIX}_${DEFAULT_KEY}`))
3334
} catch (err) { }
@@ -37,15 +38,17 @@ export class Instrument extends InstrumentBase {
3738
}
3839

3940
if (this.#canPreloadRecorder(session)) {
40-
this.#mode = session?.sessionReplayMode
41-
this.#preloadStartRecording()
42-
} else {
43-
this.importAggregator(this.#agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'))
41+
this.importRecorder().then(recorder => {
42+
recorder.startRecording(TRIGGERS.PRELOAD, session?.sessionReplayMode)
43+
}) // could handle specific fail-state behaviors with a .catch block here
4444
}
4545

46+
this.importAggregator(this.agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'), this)
47+
4648
/** If the recorder is running, we can pass error events on to the agg to help it switch to full mode later */
4749
this.ee.on('err', (e) => {
48-
if (this.#agentRef.runtime.isRecording) {
50+
if (this.blocked) return
51+
if (this.agentRef.runtime.isRecording) {
4952
this.errorNoticed = true
5053
handle(SR_EVENT_EMITTER_TYPES.ERROR_DURING_REPLAY, [e], undefined, this.featureName, this.ee)
5154
}
@@ -57,54 +60,50 @@ export class Instrument extends InstrumentBase {
5760
if (!session) { // this might be a new session if entity initializes: conservatively start recording if first-time config allows
5861
// Note: users with SR enabled, as well as these other configs enabled by-default, will be penalized by the recorder overhead EVEN IF they don't actually have or get
5962
// entitlement or sampling decision, or otherwise intentionally opted-in for the feature.
60-
return isPreloadAllowed(this.#agentRef.init)
63+
return isPreloadAllowed(this.agentRef.init)
6164
} else if (session.sessionReplayMode === MODE.FULL || session.sessionReplayMode === MODE.ERROR) {
6265
return true // existing sessions get to continue recording, regardless of this page's configs or if it has expired (conservatively)
6366
} else { // SR mode was OFF but may potentially be turned on if session resets and configs allows the new session to have replay...
64-
return isPreloadAllowed(this.#agentRef.init)
67+
return isPreloadAllowed(this.agentRef.init)
6568
}
6669
}
6770

68-
#alreadyStarted = false
6971
/**
70-
* This func is use for early pre-load recording prior to replay feature (agg) being loaded onto the page. It should only setup once, including if already called and in-progress.
72+
* Returns a promise that imports the recorder module. Only lets the recorder module be imported and instantiated once. Rejects if failed to import/instantiate.
73+
* @returns {Promise}
7174
*/
72-
async #preloadStartRecording (trigger) {
73-
if (this.#alreadyStarted) return
74-
this.#alreadyStarted = true
75-
76-
try {
77-
const { Recorder } = (await import(/* webpackChunkName: "recorder" */'../shared/recorder'))
75+
importRecorder () {
76+
/** if we already have a recorder fully set up, just return it */
77+
if (this.recorder) return Promise.resolve(this.recorder)
78+
/** conditional -- if we have never started importing, stage the import and store it in state */
79+
this.#stagedImport ??= import(/* webpackChunkName: "recorder" */'../shared/recorder')
80+
.then(({ Recorder }) => {
81+
this.recorder = new Recorder(this)
82+
/** return the recorder for promise chaining */
83+
return this.recorder
84+
})
85+
.catch(err => {
86+
this.ee.emit('internal-error', [err])
87+
this.blocked = true
88+
/** return the err for promise chaining */
89+
throw err
90+
})
7891

79-
// If startReplay() has been used by this point, we must record in full mode regardless of session preload:
80-
// Note: recorder starts here with w/e the mode is at this time, but this may be changed later (see #apiStartOrRestartReplay else-case)
81-
this.recorder ??= new Recorder({ ...this, mode: this.#mode, agentRef: this.#agentRef, trigger, timeKeeper: this.#agentRef.runtime.timeKeeper }) // if TK exists due to deferred state, pass it
82-
this.recorder.startRecording()
83-
this.abortHandler = this.recorder.stopRecording
84-
} catch (err) {
85-
this.parent.ee.emit('internal-error', [err])
86-
}
87-
this.importAggregator(this.#agentRef, () => import(/* webpackChunkName: "session_replay-aggregate" */ '../aggregate'), { recorder: this.recorder, errorNoticed: this.errorNoticed })
92+
return this.#stagedImport
8893
}
8994

9095
/**
9196
* Called whenever startReplay API is used. That could occur any time, pre or post load.
9297
*/
9398
#apiStartOrRestartReplay () {
99+
if (this.blocked) return
94100
if (this.featAggregate) { // post-load; there's possibly already an ongoing recording
95-
if (this.featAggregate.mode !== MODE.FULL) this.featAggregate.initializeRecording(MODE.FULL, true)
96-
} else { // pre-load
97-
this.#mode = MODE.FULL
98-
this.#preloadStartRecording(TRIGGERS.API)
99-
// There's a race here wherein either:
100-
// a. Recorder has not been initialized, and we've set the enforced mode, so we're good, or;
101-
// b. Record has been initialized, possibly with the "wrong" mode, so we have to correct that + restart.
102-
if (this.recorder && this.recorder.parent.mode !== MODE.FULL) {
103-
this.recorder.parent.mode = MODE.FULL
104-
this.recorder.stopRecording()
105-
this.recorder.startRecording()
106-
this.abortHandler = this.recorder.stopRecording
107-
}
101+
if (this.featAggregate.mode !== MODE.FULL) this.featAggregate.initializeRecording(MODE.FULL, true, TRIGGERS.API)
102+
} else {
103+
this.importRecorder()
104+
.then(() => {
105+
this.recorder.startRecording(TRIGGERS.API, MODE.FULL)
106+
}) // could handle specific fail-state behaviors with a .catch block here
108107
}
109108
}
110109
}

0 commit comments

Comments
 (0)