-
Notifications
You must be signed in to change notification settings - Fork 136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
⚡️ [RUM-6813] Lazy load session replay #3152
base: v6
Are you sure you want to change the base?
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v6 #3152 +/- ##
==========================================
- Coverage 93.49% 93.48% -0.01%
==========================================
Files 275 275
Lines 7393 7417 +24
Branches 1675 1678 +3
==========================================
+ Hits 6912 6934 +22
- Misses 481 483 +2 ☔ View full report in Codecov by Sentry. |
test/app/webpack.config.js
Outdated
@@ -2,7 +2,7 @@ const path = require('path') | |||
|
|||
module.exports = (_env, argv) => ({ | |||
entry: './app.ts', | |||
target: ['web', 'es5'], | |||
target: ['node', 'es2018'], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test app used to test if the SDK works for SSR was targeting the web. I fixed it to target node and es2018.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test app used to test if the SDK works for SSR was targeting the web.
Is it what is tested? As I understand it is used to test the npm setup and it's injected in a html:
header += html` <script type="text/javascript" src="./app.js"></script> ` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I forgot that the test app is also used for npm e2e 😭
@@ -14,3 +14,17 @@ export function runOnReadyState( | |||
const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED | |||
return addEventListener(configuration, window, eventName, callback, { once: true }) | |||
} | |||
|
|||
export function asyncRunOnReadyState( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now, I introduced an async version of runOnReadyState
but if we agree on this lazy loading approach I'll refactor the original runOnReadyState
to use promises.
Bundles Sizes Evolution
🚀 CPU Performance
🧠 Memory Performance
|
@@ -0,0 +1,3 @@ | |||
export function lazyLoadRecorder() { | |||
return import(/* webpackChunkName: "recorder" */ './startRecording').then((module) => module.startRecording) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ question: I have a doubt, does this means that customer need to use webpack as well? or is this a stupid question?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not exactly, I used the webpackChunkName
special comment to have a fixed chunk name to deploy our CDN files. For customers using NPM:
- if they use webpack: the chunk will have a fix name
- If they use another bundler this comment is ignored
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should use a fixed name for chunks though. It should include a hash or something to avoid cache issues, because I don't trust cloudfront/the browser to invalidate both files exactly at the same time.
We can talk about it.
})) | ||
startRecordingSpy = jasmine.createSpy('startRecording') | ||
|
||
// Workaround because using resolveTo(startRecordingSpy) is not was not working |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Workaround because using resolveTo(startRecordingSpy) is not was not working | |
// Workaround because using resolveTo(startRecordingSpy) was not working |
packages/rum/src/boot/recorderApi.ts
Outdated
const doStart = async () => { | ||
const [startRecordingImpl] = await Promise.all([ | ||
loadRecorder(), | ||
asyncRunOnReadyState(configuration, 'interactive'), | ||
]) | ||
|
||
if (state.status !== RecorderStatus.Starting) { | ||
return | ||
} | ||
|
||
const deflateEncoder = getOrCreateDeflateEncoder() | ||
if (!deflateEncoder) { | ||
state = { | ||
status: RecorderStatus.Stopped, | ||
} | ||
return | ||
} | ||
|
||
const { stop: stopRecording } = startRecordingImpl( | ||
lifeCycle, | ||
configuration, | ||
sessionManager, | ||
viewHistory, | ||
deflateEncoder | ||
) | ||
|
||
state = { | ||
status: RecorderStatus.Started, | ||
stopRecording, | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 suggestion: As we're using promise, I'll avoid having state all over the place. maybe something like this would be a bit more clean:
const doStart = async () => {
const [startRecordingImpl] = await Promise.all([
loadRecorder(),
asyncRunOnReadyState(configuration, 'interactive'),
])
if (state.status !== RecorderStatus.Starting) {
return
}
const deflateEncoder = getOrCreateDeflateEncoder()
if (!deflateEncoder) {
throw new Error('deflate encoder not initialized!');
}
const { stop: stopRecording } = startRecordingImpl(
lifeCycle,
configuration,
sessionManager,
viewHistory,
deflateEncoder
)
return stopRecording
}
And manage the state when you use it:
doStart()
.then(stop => {
state = {
status: RecorderStatus.Started,
stopRecording: stop
}
})
.catch(() => {
state = {
status: RecorderStatus.Stopped,
}
})
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting! I'll look at it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a big fan of throwing errors for control flow. Typescript does not have a syntax to tell whether a function can throw.
Having a Result
type could be nicer (ex), as it can be typechecked properly.
Or, you can make doStart
work as a state machine and return the new state:
const doStart = async () => {
const [startRecordingImpl] = await Promise.all([
loadRecorder(),
asyncRunOnReadyState(configuration, 'interactive'),
])
if (state.status !== RecorderStatus.Starting) {
return state
}
const deflateEncoder = getOrCreateDeflateEncoder()
if (!deflateEncoder) {
return {
status: RecorderStatus.Stopped,
}
}
const { stop: stopRecording } = startRecordingImpl(
lifeCycle,
configuration,
sessionManager,
viewHistory,
deflateEncoder
)
return {
status: RecorderStatus.Started,
stopRecording,
}
}
Not sure if it simplifies things though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've worked on a refacto for the recorderApi to mimic what we have in RumPublicAPI:
Having preStart and postStart strategies. I think it will also simplify the lazyLoading. I'll create a dedicated PR for it.
04a8db5
to
2205ee9
Compare
return new Promise((resolve) => { | ||
if (document.readyState === expectedReadyState || document.readyState === 'complete') { | ||
resolve() | ||
} else { | ||
const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED | ||
addEventListener(configuration, window, eventName, () => resolve(), { once: true }) | ||
} | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 suggestion:
return new Promise((resolve) => { | |
if (document.readyState === expectedReadyState || document.readyState === 'complete') { | |
resolve() | |
} else { | |
const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED | |
addEventListener(configuration, window, eventName, () => resolve(), { once: true }) | |
} | |
}) | |
return new Promise((resolve) => { | |
runOnReadyState(configuration, expectedReadyState, resolve) | |
}) |
@@ -459,51 +535,54 @@ describe('makeRecorderApi', () => { | |||
|
|||
describe('isRecording', () => { | |||
it('is false when recording has not been started', () => { | |||
setupRecorderApi() | |||
setupRecorderApi({ startSessionReplayRecordingManually: true }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ question: Why do we need this change?
@@ -514,37 +593,37 @@ describe('makeRecorderApi', () => { | |||
const VIEW_ID = 'xxx' | |||
|
|||
it('is undefined when recording has not been started', () => { | |||
setupRecorderApi() | |||
setupRecorderApi({ startSessionReplayRecordingManually: true }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ question: Here too
packages/rum/src/boot/recorderApi.ts
Outdated
viewHistory, | ||
deflateEncoder | ||
) | ||
doStart().catch(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💭 thought: It doesn't matter too much here, since the catch
callback probably won't fail, but I think we'll need to monitor()
Promise callbacks at the end of promise chains
packages/rum/src/boot/recorderApi.ts
Outdated
const doStart = async () => { | ||
const [startRecordingImpl] = await Promise.all([ | ||
loadRecorder(), | ||
asyncRunOnReadyState(configuration, 'interactive'), | ||
]) | ||
|
||
if (state.status !== RecorderStatus.Starting) { | ||
return | ||
} | ||
|
||
const deflateEncoder = getOrCreateDeflateEncoder() | ||
if (!deflateEncoder) { | ||
state = { | ||
status: RecorderStatus.Stopped, | ||
} | ||
return | ||
} | ||
|
||
const { stop: stopRecording } = startRecordingImpl( | ||
lifeCycle, | ||
configuration, | ||
sessionManager, | ||
viewHistory, | ||
deflateEncoder | ||
) | ||
|
||
state = { | ||
status: RecorderStatus.Started, | ||
stopRecording, | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not a big fan of throwing errors for control flow. Typescript does not have a syntax to tell whether a function can throw.
Having a Result
type could be nicer (ex), as it can be typechecked properly.
Or, you can make doStart
work as a state machine and return the new state:
const doStart = async () => {
const [startRecordingImpl] = await Promise.all([
loadRecorder(),
asyncRunOnReadyState(configuration, 'interactive'),
])
if (state.status !== RecorderStatus.Starting) {
return state
}
const deflateEncoder = getOrCreateDeflateEncoder()
if (!deflateEncoder) {
return {
status: RecorderStatus.Stopped,
}
}
const { stop: stopRecording } = startRecordingImpl(
lifeCycle,
configuration,
sessionManager,
viewHistory,
deflateEncoder
)
return {
status: RecorderStatus.Started,
stopRecording,
}
}
Not sure if it simplifies things though.
scripts/deploy/deploy.js
Outdated
const bundleFolder = buildBundleFolder(packageName) | ||
|
||
for (const uploadPathType of uploadPathTypes) { | ||
for (const chunkName of chunks) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 suggestion: Instead of hardcoding the chunks here, we could use fs.readdirSync
and upload all files in the bundles/
folder.
@@ -0,0 +1,3 @@ | |||
export function lazyLoadRecorder() { | |||
return import(/* webpackChunkName: "recorder" */ './startRecording').then((module) => module.startRecording) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should use a fixed name for chunks though. It should include a hash or something to avoid cache issues, because I don't trust cloudfront/the browser to invalidate both files exactly at the same time.
We can talk about it.
@@ -28,3 +28,34 @@ export function collectAsyncCalls<F extends jasmine.Func>( | |||
fail(`Unexpected extra call for spec '${currentSpec!.fullName}'`) | |||
} | |||
} | |||
export function asyncCollectAsyncCalls<F extends jasmine.Func>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💭 thought: Not a big fan of that function name. Maybe we could use the same function as above, but also return a Promise
export function collectAsyncCalls<F extends jasmine.Func>(
spy: jasmine.Spy<F>,
expectedCallsCount: number,
callback?: (calls: jasmine.Calls<F>) => void
): Promise<jasmine.Calls<F>> {
...
}
and deprecate callback
usage so we can remove it one day.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree! Sry my intention was not clear. I introduced asyncCollectAsyncCalls
for the early review, to validate the approach. My goal is to turn collectAsyncCalls in promise in a dedicated PR before merging this one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah nice!
Motivation
Use dynamic imports to lazy load Session Replay, reducing the bundle size by 3KB (compressed) for users without Session Replay. Transitioning from Webpack, which adds overhead via
__webpack_require__
, to a new bundler could save additional bytes.Changes
startRecording
using dynamic importTesting
I have gone over the contributing documentation.