Skip to content
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

Draft
wants to merge 8 commits into
base: v6
Choose a base branch
from

Conversation

amortemousque
Copy link
Contributor

@amortemousque amortemousque commented Nov 20, 2024

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

  • Configure ts module to ES2020
  • Update test app to target node and ES2018 (fix ssr)
  • Lazy load startRecording using dynamic import

Testing

  • Local
  • Staging
  • Unit
  • End to end

I have gone over the contributing documentation.

@amortemousque amortemousque changed the base branch from main to v6 November 20, 2024 16:19
@codecov-commenter
Copy link

codecov-commenter commented Nov 21, 2024

Codecov Report

Attention: Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.

Project coverage is 93.48%. Comparing base (839bae6) to head (04ff36b).
Report is 80 commits behind head on v6.

Files with missing lines Patch % Lines
packages/core/test/collectAsyncCalls.ts 88.23% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

@@ -2,7 +2,7 @@ const path = require('path')

module.exports = (_env, argv) => ({
entry: './app.ts',
target: ['web', 'es5'],
target: ['node', 'es2018'],
Copy link
Contributor Author

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.

Copy link
Collaborator

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> `

Copy link
Contributor Author

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(
Copy link
Contributor Author

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.

Copy link

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 151.13 KiB 141.86 KiB -9495 B -6.14%
Logs 50.67 KiB 50.67 KiB 0 B 0.00%
Rum Slim 101.10 KiB 101.10 KiB 0 B 0.00%
Worker 25.16 KiB 25.16 KiB 0 B 0.00%
🚀 CPU Performance
Action Name Base Average Cpu Time (ms) Local Average Cpu Time (ms) 𝚫
addglobalcontext 0.002 0.002 0.000
addaction 0.034 0.038 0.004
addtiming 0.001 0.001 0.000
adderror 0.031 0.044 0.013
startstopsessionreplayrecording 0.764 0.008 -0.756
startview 0.920 0.341 -0.579
logmessage 0.022 0.018 -0.004
🧠 Memory Performance
Action Name Base Consumption Memory (bytes) Local Consumption Memory (bytes) 𝚫 (bytes)
addglobalcontext 8.38 KiB 9.73 KiB 1.35 KiB
addaction 41.34 KiB 41.98 KiB 651 B
addtiming 7.69 KiB 8.13 KiB 454 B
adderror 44.28 KiB 45.73 KiB 1.45 KiB
startstopsessionreplayrecording 5.85 KiB 6.09 KiB 249 B
startview 407.29 KiB 406.37 KiB -943 B
logmessage 42.05 KiB 42.32 KiB 278 B

🔗 RealWorld

@@ -0,0 +1,3 @@
export function lazyLoadRecorder() {
return import(/* webpackChunkName: "recorder" */ './startRecording').then((module) => module.startRecording)
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Member

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Workaround because using resolveTo(startRecordingSpy) is not was not working
// Workaround because using resolveTo(startRecordingSpy) was not working

Comment on lines 169 to 199
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,
}
}
Copy link
Collaborator

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,
    }
  })

Copy link
Contributor Author

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

Copy link
Member

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.

Copy link
Contributor Author

@amortemousque amortemousque Nov 26, 2024

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.

Comment on lines 22 to 29
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 })
}
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion:

Suggested change
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 })
Copy link
Member

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 })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏Here too

viewHistory,
deflateEncoder
)
doStart().catch(() => {
Copy link
Member

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

Comment on lines 169 to 199
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,
}
}
Copy link
Member

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.

const bundleFolder = buildBundleFolder(packageName)

for (const uploadPathType of uploadPathTypes) {
for (const chunkName of chunks) {
Copy link
Member

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)
Copy link
Member

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>(
Copy link
Member

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.

Copy link
Contributor Author

@amortemousque amortemousque Nov 26, 2024

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants