From 000c2cc2678d8ce15a63adeeb2367a2ca335ab29 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Sun, 22 Sep 2024 23:40:17 -0700 Subject: [PATCH 1/8] fix(session replay): update and consolidate tests --- .../src/session-replay.ts | 39 +++++++-- .../test/session-replay.test.ts | 82 +++++++++++++++++-- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 42fe517b6..6ed61ec71 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -30,6 +30,7 @@ import { SessionReplayOptions, } from './typings/session-replay'; import { VERSION } from './version'; +import type { eventWithTime } from '@amplitude/rrweb-types'; type PageLeaveFn = (e: PageTransitionEvent | Event) => void; @@ -309,6 +310,36 @@ export class SessionReplay implements AmplitudeSessionReplay { return maskSelector as unknown as string; } + compressEvents = (event: eventWithTime) => { + const packedEvent = pack(event); + return JSON.stringify(packedEvent); + }; + + addCompressedEvents = (event: eventWithTime, sessionId: number) => { + this.loggerProvider.log('Compressing event for session replay: ', event); + const compressedEvent = this.compressEvents(event); + const deviceId = this.getDeviceId(); + this.eventsManager && + deviceId && + this.eventsManager.addEvent({ event: { type: 'replay', data: compressedEvent }, sessionId, deviceId }); + }; + + deferEventCompression = (event: eventWithTime, sessionId: number) => { + const globalScope = getGlobalScope(); + // In case the browser does not support requestIdleCallback, we will compress the event immediately + if (globalScope && 'requestIdleCallback' in globalScope) { + requestIdleCallback( + () => { + this.loggerProvider.log('Adding event to idle callback queue: ', event); + this.addCompressedEvents(event, sessionId); + }, + { timeout: 2000 }, + ); // Timeout and run after 2 seconds + } else { + this.addCompressedEvents(event, sessionId); + } + }; + recordEvents() { const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; @@ -327,13 +358,9 @@ export class SessionReplay implements AmplitudeSessionReplay { this.sendEvents(); return; } - const eventString = JSON.stringify(event); - const deviceId = this.getDeviceId(); - this.eventsManager && - deviceId && - this.eventsManager.addEvent({ event: { type: 'replay', data: eventString }, sessionId, deviceId }); + + this.deferEventCompression(event, sessionId); }, - packFn: pack, inlineStylesheet: this.config.shouldInlineStylesheet, hooks: { mouseInteraction: diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index ab32d0551..0244ea8f0 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -37,6 +37,7 @@ const samplingConfig = { describe('SessionReplay', () => { const { record } = RRWeb as MockedRRWeb; let originalFetch: typeof global.fetch; + let deferEvents: typeof global.requestIdleCallback; let globalSpy: jest.SpyInstance; const mockLoggerProvider: MockedLogger = { error: jest.fn(), @@ -117,12 +118,19 @@ describe('SessionReplay', () => { status: 200, }); }); + deferEvents = global.requestIdleCallback; + (global.requestIdleCallback as jest.Mock) = jest.fn((callback, options) => { + setTimeout(() => { + callback(); + }, (options?.timeout as number) || 0); + }); globalSpy = jest.spyOn(AnalyticsClientCommon, 'getGlobalScope').mockReturnValue(mockGlobalScope); }); afterEach(() => { jest.resetAllMocks(); jest.spyOn(global.Math, 'random').mockRestore(); global.fetch = originalFetch; + global.requestIdleCallback = deferEvents; jest.useRealTimers(); }); describe('init', () => { @@ -862,23 +870,74 @@ describe('SessionReplay', () => { expect(currentSequenceEvents).toEqual(undefined); }); - test('should addEvent to eventManager', async () => { + test('should defer event to when recording', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + // const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] + // .value; + sessionReplay.recordEvents(); + if (!sessionReplay.eventsManager) { + throw new Error('Did not call init'); + } + // const addEventSpy = jest.spyOn(sessionReplay.eventsManager, 'addEvent'); + const deferEventSpy = jest.spyOn(sessionReplay, 'deferEventCompression'); + // const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); + // expect(currentSequenceEvents).toEqual(undefined); + const recordArg = record.mock.calls[0][0]; + // Emit event, which is stored in class and IDB + recordArg?.emit && recordArg?.emit(mockEvent); + jest.advanceTimersByTime(2000); + expect(deferEventSpy).toHaveBeenCalledTimes(1); + expect(deferEventSpy).toHaveBeenCalledWith(mockEvent, mockOptions.sessionId); + // expect(addEventSpy).toHaveBeenCalledTimes(1); + // expect(addEventSpy).toHaveBeenCalledWith({ + // event: { type: 'replay', data: mockEventString }, + // sessionId: mockOptions.sessionId, + // deviceId: mockOptions.deviceId, + // }); + }); + + test('should call requestIdleCallback when deferring events', async () => { + globalSpy = jest + .spyOn(AnalyticsClientCommon, 'getGlobalScope') + .mockReturnValue({ ...mockGlobalScope, requestIdleCallback: global.requestIdleCallback }); + await sessionReplay.init(apiKey, mockOptions).promise; + const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] + .value; + if (!sessionReplay.eventsManager) { + throw new Error('Did not call init'); + } + const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); + expect(currentSequenceEvents).toEqual(undefined); + const addCompressedEventSpy = jest.spyOn(sessionReplay, 'addCompressedEvents'); + const compressEventSpy = jest.spyOn(sessionReplay, 'compressEvents'); + + sessionReplay.deferEventCompression(mockEvent, 123); + jest.advanceTimersByTime(2000); + + expect(compressEventSpy).toHaveBeenCalledTimes(1); + expect(addCompressedEventSpy).toHaveBeenCalledTimes(1); + expect(addCompressedEventSpy).toHaveBeenCalledWith(mockEvent, mockOptions.sessionId); + expect(global.requestIdleCallback).toHaveBeenCalled(); + }); + + test('should add compressed events to event manger', async () => { await sessionReplay.init(apiKey, mockOptions).promise; const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] .value; - sessionReplay.recordEvents(); if (!sessionReplay.eventsManager) { throw new Error('Did not call init'); } const addEventSpy = jest.spyOn(sessionReplay.eventsManager, 'addEvent'); + const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); expect(currentSequenceEvents).toEqual(undefined); - const recordArg = record.mock.calls[0][0]; - // Emit event, which is stored in class and IDB - recordArg?.emit && recordArg?.emit(mockEvent); + + sessionReplay.addCompressedEvents(mockEvent, 123); + const events = sessionReplay.compressEvents(mockEvent); + expect(addEventSpy).toHaveBeenCalledTimes(1); expect(addEventSpy).toHaveBeenCalledWith({ - event: { type: 'replay', data: mockEventString }, + event: { type: 'replay', data: events }, sessionId: mockOptions.sessionId, deviceId: mockOptions.deviceId, }); @@ -1211,4 +1270,15 @@ describe('SessionReplay', () => { expect(mockLoggerProvider.debug).toHaveBeenCalled(); }); }); + + // describe('defer event compression', () => { + // test('should defer event compression', async () => { + // const sessionId = 123 + // requestIdleCallback(() => { + // sessionReplay.addCompressedEvents(mockEvent, sessionId) + // }, { timeout: 2000 }); + // jest.advanceTimersByTime(2000); + // expect(mockLoggerProvider.log).toHaveBeenCalled(); + // }); + // }) }); From ba5712d74ae359127e226a7cea83d2d4c331ab41 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Sun, 22 Sep 2024 23:42:10 -0700 Subject: [PATCH 2/8] chore(session replay): remove console logs --- .../test/session-replay.test.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 0244ea8f0..c3ee01c3e 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -872,28 +872,19 @@ describe('SessionReplay', () => { test('should defer event to when recording', async () => { await sessionReplay.init(apiKey, mockOptions).promise; - // const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] - // .value; sessionReplay.recordEvents(); if (!sessionReplay.eventsManager) { throw new Error('Did not call init'); } - // const addEventSpy = jest.spyOn(sessionReplay.eventsManager, 'addEvent'); const deferEventSpy = jest.spyOn(sessionReplay, 'deferEventCompression'); - // const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); - // expect(currentSequenceEvents).toEqual(undefined); + const recordArg = record.mock.calls[0][0]; - // Emit event, which is stored in class and IDB recordArg?.emit && recordArg?.emit(mockEvent); + jest.advanceTimersByTime(2000); + expect(deferEventSpy).toHaveBeenCalledTimes(1); expect(deferEventSpy).toHaveBeenCalledWith(mockEvent, mockOptions.sessionId); - // expect(addEventSpy).toHaveBeenCalledTimes(1); - // expect(addEventSpy).toHaveBeenCalledWith({ - // event: { type: 'replay', data: mockEventString }, - // sessionId: mockOptions.sessionId, - // deviceId: mockOptions.deviceId, - // }); }); test('should call requestIdleCallback when deferring events', async () => { From ec6d792a5008f5582565a88ee7127086b9433a37 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Sun, 22 Sep 2024 23:42:39 -0700 Subject: [PATCH 3/8] chore(session replay): remove unnecessary test --- .../test/session-replay.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index c3ee01c3e..14ae6e8d3 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -1261,15 +1261,4 @@ describe('SessionReplay', () => { expect(mockLoggerProvider.debug).toHaveBeenCalled(); }); }); - - // describe('defer event compression', () => { - // test('should defer event compression', async () => { - // const sessionId = 123 - // requestIdleCallback(() => { - // sessionReplay.addCompressedEvents(mockEvent, sessionId) - // }, { timeout: 2000 }); - // jest.advanceTimersByTime(2000); - // expect(mockLoggerProvider.log).toHaveBeenCalled(); - // }); - // }) }); From d337e2f61563d0d5db3739f05102901e096090d5 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Mon, 23 Sep 2024 10:22:17 -0700 Subject: [PATCH 4/8] fix(session replay): only check requestIdleCallback on initialization of recording --- .../src/session-replay.ts | 20 +++++++++---------- .../test/session-replay.test.ts | 11 ++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 6ed61ec71..fc5a0273b 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -315,8 +315,8 @@ export class SessionReplay implements AmplitudeSessionReplay { return JSON.stringify(packedEvent); }; - addCompressedEvents = (event: eventWithTime, sessionId: number) => { - this.loggerProvider.log('Compressing event for session replay: ', event); + addCompressedEvent = (event: eventWithTime, sessionId: number) => { + this.loggerProvider.debug('Compressing event for session replay: ', event); const compressedEvent = this.compressEvents(event); const deviceId = this.getDeviceId(); this.eventsManager && @@ -324,31 +324,31 @@ export class SessionReplay implements AmplitudeSessionReplay { this.eventsManager.addEvent({ event: { type: 'replay', data: compressedEvent }, sessionId, deviceId }); }; - deferEventCompression = (event: eventWithTime, sessionId: number) => { - const globalScope = getGlobalScope(); + deferEventCompression = (canDelayCompression: boolean | undefined, event: eventWithTime, sessionId: number) => { // In case the browser does not support requestIdleCallback, we will compress the event immediately - if (globalScope && 'requestIdleCallback' in globalScope) { + if (canDelayCompression) { requestIdleCallback( () => { - this.loggerProvider.log('Adding event to idle callback queue: ', event); - this.addCompressedEvents(event, sessionId); + this.loggerProvider.debug('Adding event to idle callback queue: ', event); + this.addCompressedEvent(event, sessionId); }, { timeout: 2000 }, ); // Timeout and run after 2 seconds } else { - this.addCompressedEvents(event, sessionId); + this.addCompressedEvent(event, sessionId); } }; recordEvents() { + const globalScope = getGlobalScope(); const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; + const canDelayCompression = globalScope && 'requestIdleCallback' in globalScope; if (!shouldRecord || !sessionId || !this.config) { return; } this.stopRecordingEvents(); const privacyConfig = this.config.privacyConfig; - this.loggerProvider.log('Session Replay capture beginning.'); this.recordCancelCallback = record({ emit: (event) => { @@ -359,7 +359,7 @@ export class SessionReplay implements AmplitudeSessionReplay { return; } - this.deferEventCompression(event, sessionId); + this.deferEventCompression(canDelayCompression, event, sessionId); }, inlineStylesheet: this.config.shouldInlineStylesheet, hooks: { diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 14ae6e8d3..af1f3e75f 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -871,6 +871,9 @@ describe('SessionReplay', () => { }); test('should defer event to when recording', async () => { + globalSpy = jest + .spyOn(AnalyticsClientCommon, 'getGlobalScope') + .mockReturnValue({ ...mockGlobalScope, requestIdleCallback: global.requestIdleCallback }); await sessionReplay.init(apiKey, mockOptions).promise; sessionReplay.recordEvents(); if (!sessionReplay.eventsManager) { @@ -884,7 +887,7 @@ describe('SessionReplay', () => { jest.advanceTimersByTime(2000); expect(deferEventSpy).toHaveBeenCalledTimes(1); - expect(deferEventSpy).toHaveBeenCalledWith(mockEvent, mockOptions.sessionId); + expect(deferEventSpy).toHaveBeenCalledWith(true, mockEvent, mockOptions.sessionId); }); test('should call requestIdleCallback when deferring events', async () => { @@ -899,10 +902,10 @@ describe('SessionReplay', () => { } const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); expect(currentSequenceEvents).toEqual(undefined); - const addCompressedEventSpy = jest.spyOn(sessionReplay, 'addCompressedEvents'); + const addCompressedEventSpy = jest.spyOn(sessionReplay, 'addCompressedEvent'); const compressEventSpy = jest.spyOn(sessionReplay, 'compressEvents'); - sessionReplay.deferEventCompression(mockEvent, 123); + sessionReplay.deferEventCompression(true, mockEvent, 123); jest.advanceTimersByTime(2000); expect(compressEventSpy).toHaveBeenCalledTimes(1); @@ -923,7 +926,7 @@ describe('SessionReplay', () => { const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); expect(currentSequenceEvents).toEqual(undefined); - sessionReplay.addCompressedEvents(mockEvent, 123); + sessionReplay.addCompressedEvent(mockEvent, 123); const events = sessionReplay.compressEvents(mockEvent); expect(addEventSpy).toHaveBeenCalledTimes(1); From df7983ea1d1a255cdb770a0e8cab23f875b5a2ea Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Mon, 23 Sep 2024 22:52:06 -0700 Subject: [PATCH 5/8] feat(session replay): performance config for requestIdleCallback --- .../src/session-replay.ts | 1 + .../src/typings/session-replay.ts | 6 ++ .../src/config/local-config.ts | 3 + .../src/config/types.ts | 5 ++ .../src/session-replay.ts | 43 +++--------- .../test/session-replay.test.ts | 67 ------------------- 6 files changed, 24 insertions(+), 101 deletions(-) diff --git a/packages/plugin-session-replay-browser/src/session-replay.ts b/packages/plugin-session-replay-browser/src/session-replay.ts index be5a45918..5655f868b 100644 --- a/packages/plugin-session-replay-browser/src/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/session-replay.ts @@ -63,6 +63,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin { configEndpointUrl: this.options.configEndpointUrl, shouldInlineStylesheet: this.options.shouldInlineStylesheet, version: { type: 'plugin', version: VERSION }, + performanceConfig: this.options.performanceConfig, }).promise; } diff --git a/packages/plugin-session-replay-browser/src/typings/session-replay.ts b/packages/plugin-session-replay-browser/src/typings/session-replay.ts index dfdb0e2cc..87ccd1597 100644 --- a/packages/plugin-session-replay-browser/src/typings/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/typings/session-replay.ts @@ -9,6 +9,11 @@ export interface SessionReplayPrivacyConfig { maskSelector?: string[]; unmaskSelector?: string[]; } + +export interface SessionReplayPerformanceConfig { + enabled: boolean; +} + export interface SessionReplayOptions { sampleRate?: number; privacyConfig?: SessionReplayPrivacyConfig; @@ -16,4 +21,5 @@ export interface SessionReplayOptions { forceSessionTracking?: boolean; configEndpointUrl?: string; shouldInlineStylesheet?: boolean; + performanceConfig?: SessionReplayPerformanceConfig; } diff --git a/packages/session-replay-browser/src/config/local-config.ts b/packages/session-replay-browser/src/config/local-config.ts index d2ae6b2b9..1237d609b 100644 --- a/packages/session-replay-browser/src/config/local-config.ts +++ b/packages/session-replay-browser/src/config/local-config.ts @@ -7,6 +7,7 @@ import { SessionReplayLocalConfig as ISessionReplayLocalConfig, InteractionConfig, PrivacyConfig, + SessionReplayPerformanceConfig, SessionReplayVersion, } from './types'; @@ -26,6 +27,7 @@ export class SessionReplayLocalConfig extends Config implements ISessionReplayLo configEndpointUrl?: string; shouldInlineStylesheet?: boolean; version?: SessionReplayVersion; + performanceConfig?: SessionReplayPerformanceConfig; constructor(apiKey: string, options: SessionReplayOptions) { const defaultConfig = getDefaultConfig(); @@ -45,6 +47,7 @@ export class SessionReplayLocalConfig extends Config implements ISessionReplayLo this.configEndpointUrl = options.configEndpointUrl; this.shouldInlineStylesheet = options.shouldInlineStylesheet; this.version = options.version; + this.performanceConfig = options.performanceConfig; if (options.privacyConfig) { this.privacyConfig = options.privacyConfig; diff --git a/packages/session-replay-browser/src/config/types.ts b/packages/session-replay-browser/src/config/types.ts index 8d731f442..a4bbdfe7e 100644 --- a/packages/session-replay-browser/src/config/types.ts +++ b/packages/session-replay-browser/src/config/types.ts @@ -49,6 +49,7 @@ export interface SessionReplayLocalConfig extends Config { configEndpointUrl?: string; shouldInlineStylesheet?: boolean; version?: SessionReplayVersion; + performanceConfig?: SessionReplayPerformanceConfig; } export interface SessionReplayJoinedConfig extends SessionReplayLocalConfig { @@ -72,4 +73,8 @@ export interface SessionReplayVersion { type: SessionReplayType; } +export interface SessionReplayPerformanceConfig { + enabled: boolean; +} + export type SessionReplayType = 'standalone' | 'plugin' | 'segment'; diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index fc5a0273b..a0022c9ef 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -1,7 +1,7 @@ import { getAnalyticsConnector, getGlobalScope } from '@amplitude/analytics-client-common'; import { Logger, returnWrapper } from '@amplitude/analytics-core'; import { Logger as ILogger, LogLevel } from '@amplitude/analytics-types'; -import { pack, record } from '@amplitude/rrweb'; +import { record } from '@amplitude/rrweb'; import { scrollCallback } from '@amplitude/rrweb-types'; import { createSessionReplayJoinedConfigGenerator } from './config/joined-config'; import { SessionReplayJoinedConfig, SessionReplayJoinedConfigGenerator } from './config/types'; @@ -30,7 +30,7 @@ import { SessionReplayOptions, } from './typings/session-replay'; import { VERSION } from './version'; -import type { eventWithTime } from '@amplitude/rrweb-types'; +import { EventCompressor } from './events/event-compressor'; type PageLeaveFn = (e: PageTransitionEvent | Event) => void; @@ -43,6 +43,7 @@ export class SessionReplay implements AmplitudeSessionReplay { loggerProvider: ILogger; recordCancelCallback: ReturnType | null = null; eventCount = 0; + eventCompressor: EventCompressor | undefined; // Visible for testing pageLeaveFns: PageLeaveFn[] = []; @@ -130,6 +131,7 @@ export class SessionReplay implements AmplitudeSessionReplay { } this.eventsManager = new MultiEventManager<'replay' | 'interaction', string>(...managers); + this.eventCompressor = new EventCompressor(this.eventsManager, this.config, this.getDeviceId()); this.loggerProvider.log('Installing @amplitude/session-replay-browser.'); @@ -310,40 +312,10 @@ export class SessionReplay implements AmplitudeSessionReplay { return maskSelector as unknown as string; } - compressEvents = (event: eventWithTime) => { - const packedEvent = pack(event); - return JSON.stringify(packedEvent); - }; - - addCompressedEvent = (event: eventWithTime, sessionId: number) => { - this.loggerProvider.debug('Compressing event for session replay: ', event); - const compressedEvent = this.compressEvents(event); - const deviceId = this.getDeviceId(); - this.eventsManager && - deviceId && - this.eventsManager.addEvent({ event: { type: 'replay', data: compressedEvent }, sessionId, deviceId }); - }; - - deferEventCompression = (canDelayCompression: boolean | undefined, event: eventWithTime, sessionId: number) => { - // In case the browser does not support requestIdleCallback, we will compress the event immediately - if (canDelayCompression) { - requestIdleCallback( - () => { - this.loggerProvider.debug('Adding event to idle callback queue: ', event); - this.addCompressedEvent(event, sessionId); - }, - { timeout: 2000 }, - ); // Timeout and run after 2 seconds - } else { - this.addCompressedEvent(event, sessionId); - } - }; - recordEvents() { - const globalScope = getGlobalScope(); const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; - const canDelayCompression = globalScope && 'requestIdleCallback' in globalScope; + // const canDelayCompression = globalScope && 'requestIdleCallback' in globalScope; if (!shouldRecord || !sessionId || !this.config) { return; } @@ -359,7 +331,10 @@ export class SessionReplay implements AmplitudeSessionReplay { return; } - this.deferEventCompression(canDelayCompression, event, sessionId); + if (this.eventCompressor) { + // Schedule processing during idle time if the browser supports requestIdleCallback + this.eventCompressor.enqueueEvent(event, sessionId); + } }, inlineStylesheet: this.config.shouldInlineStylesheet, hooks: { diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index af1f3e75f..bffded17f 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -870,73 +870,6 @@ describe('SessionReplay', () => { expect(currentSequenceEvents).toEqual(undefined); }); - test('should defer event to when recording', async () => { - globalSpy = jest - .spyOn(AnalyticsClientCommon, 'getGlobalScope') - .mockReturnValue({ ...mockGlobalScope, requestIdleCallback: global.requestIdleCallback }); - await sessionReplay.init(apiKey, mockOptions).promise; - sessionReplay.recordEvents(); - if (!sessionReplay.eventsManager) { - throw new Error('Did not call init'); - } - const deferEventSpy = jest.spyOn(sessionReplay, 'deferEventCompression'); - - const recordArg = record.mock.calls[0][0]; - recordArg?.emit && recordArg?.emit(mockEvent); - - jest.advanceTimersByTime(2000); - - expect(deferEventSpy).toHaveBeenCalledTimes(1); - expect(deferEventSpy).toHaveBeenCalledWith(true, mockEvent, mockOptions.sessionId); - }); - - test('should call requestIdleCallback when deferring events', async () => { - globalSpy = jest - .spyOn(AnalyticsClientCommon, 'getGlobalScope') - .mockReturnValue({ ...mockGlobalScope, requestIdleCallback: global.requestIdleCallback }); - await sessionReplay.init(apiKey, mockOptions).promise; - const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] - .value; - if (!sessionReplay.eventsManager) { - throw new Error('Did not call init'); - } - const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); - expect(currentSequenceEvents).toEqual(undefined); - const addCompressedEventSpy = jest.spyOn(sessionReplay, 'addCompressedEvent'); - const compressEventSpy = jest.spyOn(sessionReplay, 'compressEvents'); - - sessionReplay.deferEventCompression(true, mockEvent, 123); - jest.advanceTimersByTime(2000); - - expect(compressEventSpy).toHaveBeenCalledTimes(1); - expect(addCompressedEventSpy).toHaveBeenCalledTimes(1); - expect(addCompressedEventSpy).toHaveBeenCalledWith(mockEvent, mockOptions.sessionId); - expect(global.requestIdleCallback).toHaveBeenCalled(); - }); - - test('should add compressed events to event manger', async () => { - await sessionReplay.init(apiKey, mockOptions).promise; - const createEventsIDBStoreInstance = await (SessionReplayIDB.createEventsIDBStore as jest.Mock).mock.results[0] - .value; - if (!sessionReplay.eventsManager) { - throw new Error('Did not call init'); - } - const addEventSpy = jest.spyOn(sessionReplay.eventsManager, 'addEvent'); - - const currentSequenceEvents = await createEventsIDBStoreInstance.db.get('sessionCurrentSequence', 123); - expect(currentSequenceEvents).toEqual(undefined); - - sessionReplay.addCompressedEvent(mockEvent, 123); - const events = sessionReplay.compressEvents(mockEvent); - - expect(addEventSpy).toHaveBeenCalledTimes(1); - expect(addEventSpy).toHaveBeenCalledWith({ - event: { type: 'replay', data: events }, - sessionId: mockOptions.sessionId, - deviceId: mockOptions.deviceId, - }); - }); - test('should stop recording before starting anew', async () => { await sessionReplay.init(apiKey, mockOptions).promise; const stopRecordingMock = jest.fn(); From ecde7f2ebe5fba8085cf532f1bdbfaed9c5211d7 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Mon, 23 Sep 2024 22:54:06 -0700 Subject: [PATCH 6/8] chore(session replay): missing files for event compression --- .../src/events/event-compressor.ts | 95 +++++++++++ .../test/event-compressor.test.ts | 160 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 packages/session-replay-browser/src/events/event-compressor.ts create mode 100644 packages/session-replay-browser/test/event-compressor.test.ts diff --git a/packages/session-replay-browser/src/events/event-compressor.ts b/packages/session-replay-browser/src/events/event-compressor.ts new file mode 100644 index 000000000..648342f0e --- /dev/null +++ b/packages/session-replay-browser/src/events/event-compressor.ts @@ -0,0 +1,95 @@ +import type { eventWithTime } from '@amplitude/rrweb-types'; +import { SessionReplayJoinedConfig } from 'src/config/types'; +import { SessionReplayEventsManager } from 'src/typings/session-replay'; +import { pack } from '@amplitude/rrweb'; +import { getGlobalScope } from '@amplitude/analytics-client-common'; + +interface TaskQueue { + event: eventWithTime; + sessionId: number; +} + +export class EventCompressor { + taskQueue: TaskQueue[] = []; + isProcessing = false; + eventsManager?: SessionReplayEventsManager<'replay' | 'interaction', string>; + config: SessionReplayJoinedConfig; + deviceId: string | undefined; + canUseIdleCallback: boolean | undefined; + + constructor( + eventsManager: SessionReplayEventsManager<'replay' | 'interaction', string>, + config: SessionReplayJoinedConfig, + deviceId: string | undefined, + ) { + const globalScope = getGlobalScope(); + this.canUseIdleCallback = globalScope && 'requestIdleCallback' in globalScope; + this.eventsManager = eventsManager; + this.config = config; + this.deviceId = deviceId; + } + + // Schedule processing during idle time + public scheduleIdleProcessing(): void { + if (!this.isProcessing) { + this.isProcessing = true; + requestIdleCallback( + (idleDeadline) => { + this.processQueue(idleDeadline); + }, + { timeout: 2000 }, + ); + } + } + + // Add an event to the task queue if idle callback is supported or compress the event directly + public enqueueEvent(event: eventWithTime, sessionId: number): void { + if (this.canUseIdleCallback && this.config.performanceConfig?.enabled) { + this.taskQueue.push({ event, sessionId }); + this.scheduleIdleProcessing(); + } else { + this.addCompressedEvent(event, sessionId); + } + } + + // Process the task queue during idle time + public processQueue(idleDeadline: IdleDeadline): void { + // Process tasks while there's idle time or until the max number of tasks is reached + while (this.taskQueue.length > 0 && (idleDeadline.timeRemaining() > 0 || idleDeadline.didTimeout)) { + const task = this.taskQueue.shift(); + if (task) { + const { event, sessionId } = task; + this.addCompressedEvent(event, sessionId); + } + } + + // If there are still tasks in the queue, schedule the next idle callback + if (this.taskQueue.length > 0) { + requestIdleCallback( + (idleDeadline) => { + this.processQueue(idleDeadline); + }, + { timeout: 2000 }, + ); + } else { + this.isProcessing = false; + } + } + + compressEvents = (event: eventWithTime) => { + const packedEvent = pack(event); + return JSON.stringify(packedEvent); + }; + + public addCompressedEvent = (event: eventWithTime, sessionId: number) => { + const compressedEvent = this.compressEvents(event); + + if (this.eventsManager && this.deviceId) { + this.eventsManager.addEvent({ + event: { type: 'replay', data: compressedEvent }, + sessionId, + deviceId: this.deviceId, + }); + } + }; +} diff --git a/packages/session-replay-browser/test/event-compressor.test.ts b/packages/session-replay-browser/test/event-compressor.test.ts new file mode 100644 index 000000000..5a5a785d6 --- /dev/null +++ b/packages/session-replay-browser/test/event-compressor.test.ts @@ -0,0 +1,160 @@ +import { Logger } from '@amplitude/analytics-types'; +import { SessionReplayLocalConfig } from '../src/config/local-config'; +import { EventCompressor } from '../src/events/event-compressor'; +import { createEventsManager } from '../src/events/events-manager'; +import { SessionReplayEventsManager } from '../src/typings/session-replay'; + +const mockEvent = { + type: 4, + data: { href: 'https://analytics.amplitude.com/', width: 1728, height: 154 }, + timestamp: 1687358660935, +}; + +type MockedLogger = jest.Mocked; + +describe('EventCompressor', () => { + let eventsManager: SessionReplayEventsManager<'replay' | 'interaction', string>; + let eventCompressor: EventCompressor; + const mockRequestIdleCallback = jest.fn((callback: (deadline: IdleDeadline) => void) => { + const mockIdleDeadline: IdleDeadline = { + timeRemaining: () => 50, + didTimeout: false, + }; + return callback(mockIdleDeadline); + }); + (global.requestIdleCallback as jest.Mock) = mockRequestIdleCallback; + const mockLoggerProvider: MockedLogger = { + error: jest.fn(), + log: jest.fn(), + disable: jest.fn(), + enable: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + const deviceId = '4abce3b0-0b1b-4b3b-8b3b-3b0b1b4b3b8b'; + const sessionId = 123; + let deferEvents: typeof global.requestIdleCallback; + const config = new SessionReplayLocalConfig('static_key', { + loggerProvider: mockLoggerProvider, + sampleRate: 1, + performanceConfig: { + enabled: true, + }, + }); + + beforeEach(async () => { + eventsManager = await createEventsManager<'replay'>({ + config, + type: 'replay', + }); + eventCompressor = new EventCompressor(eventsManager, config, deviceId); + deferEvents = global.requestIdleCallback; + }); + + afterEach(() => { + jest.resetAllMocks(); + global.requestIdleCallback = deferEvents; + jest.useRealTimers(); + }); + + test('should schedule idle processing if not already processing', () => { + const scheduleIdleProcessingMock = jest.spyOn(eventCompressor, 'scheduleIdleProcessing'); + expect(eventCompressor.isProcessing).toBe(false); + + eventCompressor.enqueueEvent(mockEvent, 123); + + expect(scheduleIdleProcessingMock).toHaveBeenCalledTimes(1); + expect(mockRequestIdleCallback).toHaveBeenCalledTimes(1); + }); + + test('should not schedule idle processing if already processing', () => { + eventCompressor.isProcessing = true; + eventCompressor.scheduleIdleProcessing(); + expect(mockRequestIdleCallback).not.toHaveBeenCalled(); + }); + + test('should immediately compress and add the event if idle callback is not supported', () => { + eventCompressor.canUseIdleCallback = false; + const addEventMock = jest.spyOn(eventsManager, 'addEvent'); + eventCompressor.enqueueEvent(mockEvent, sessionId); + + expect(eventCompressor.taskQueue.length).toBe(0); + expect(addEventMock).toHaveBeenCalled(); + }); + + test('should process events in the queue and add compressed events', () => { + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + + const mockIdleDeadline = { timeRemaining: () => 0, didTimeout: true } as IdleDeadline; + + const addEventMock = jest.spyOn(eventsManager, 'addEvent'); + + eventCompressor.processQueue(mockIdleDeadline); + + expect(addEventMock).toHaveBeenCalled(); + expect(eventCompressor.taskQueue.length).toBe(0); + }); + + test('should call requestIdleCallback if there are still tasks in the queue', () => { + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + + const mockIdleDeadline = { timeRemaining: () => 0, didTimeout: false } as IdleDeadline; + + const processQueueMock = jest.spyOn(eventCompressor, 'processQueue'); + + eventCompressor.processQueue(mockIdleDeadline); + expect(processQueueMock).toHaveBeenCalledTimes(1); + expect(mockRequestIdleCallback).toHaveBeenCalled(); + }); + + test('should not call requestIdleCallback if preformance config is undefined', () => { + eventCompressor.config.performanceConfig = undefined; + + const addCompressedEventMock = jest.spyOn(eventCompressor, 'addCompressedEvent'); + + eventCompressor.enqueueEvent(mockEvent, sessionId); + + expect(eventCompressor.taskQueue.length).toBe(0); + expect(addCompressedEventMock).toHaveBeenCalledWith(mockEvent, sessionId); + }); + + test('should set isProcessing to false when taskQueue is empty', () => { + eventCompressor.taskQueue = []; + const mockIdleDeadline = { timeRemaining: () => 0, didTimeout: false } as IdleDeadline; + + const processQueueMock = jest.spyOn(eventCompressor, 'processQueue'); + eventCompressor.processQueue(mockIdleDeadline); + + expect(processQueueMock).toHaveBeenCalled(); + expect(eventCompressor.isProcessing).toBe(false); + }); + + test('should schedule another idle callback if there are still tasks', () => { + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + eventCompressor.taskQueue.push({ event: mockEvent, sessionId }); + + const mockIdleDeadline = { + timeRemaining: () => 0, + didTimeout: false, + } as IdleDeadline; + + const processQueueMock = jest.spyOn(eventCompressor, 'processQueue'); + const requestIdleCallbackSpy = jest.spyOn(global, 'requestIdleCallback'); + + eventCompressor.processQueue(mockIdleDeadline); + + expect(processQueueMock).toHaveBeenCalledTimes(1); + + // Verify that requestIdleCallback is called again for the remaining tasks + expect(requestIdleCallbackSpy).toHaveBeenCalledTimes(1); + + // Simulate the next recursive call by invoking the callback manually + const idleCallback = requestIdleCallbackSpy.mock.calls[0][0]; + idleCallback(mockIdleDeadline); + + // Ensure processQueue was called recursively + expect(processQueueMock).toHaveBeenCalledTimes(2); + }); +}); From fd677929ef772b46e103d002230e87fa2d3b587d Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Tue, 24 Sep 2024 08:57:31 -0700 Subject: [PATCH 7/8] feat(session replay): adding timeout into config --- .../src/typings/session-replay.ts | 1 + packages/session-replay-browser/src/config/types.ts | 1 + .../src/events/event-compressor.ts | 11 +++++++---- packages/session-replay-browser/src/session-replay.ts | 1 - .../test/event-compressor.test.ts | 1 + 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/plugin-session-replay-browser/src/typings/session-replay.ts b/packages/plugin-session-replay-browser/src/typings/session-replay.ts index 87ccd1597..7cb38c2ac 100644 --- a/packages/plugin-session-replay-browser/src/typings/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/typings/session-replay.ts @@ -12,6 +12,7 @@ export interface SessionReplayPrivacyConfig { export interface SessionReplayPerformanceConfig { enabled: boolean; + timeout?: number; } export interface SessionReplayOptions { diff --git a/packages/session-replay-browser/src/config/types.ts b/packages/session-replay-browser/src/config/types.ts index a4bbdfe7e..26df50188 100644 --- a/packages/session-replay-browser/src/config/types.ts +++ b/packages/session-replay-browser/src/config/types.ts @@ -75,6 +75,7 @@ export interface SessionReplayVersion { export interface SessionReplayPerformanceConfig { enabled: boolean; + timeout?: number; } export type SessionReplayType = 'standalone' | 'plugin' | 'segment'; diff --git a/packages/session-replay-browser/src/events/event-compressor.ts b/packages/session-replay-browser/src/events/event-compressor.ts index 648342f0e..bbd8c96c7 100644 --- a/packages/session-replay-browser/src/events/event-compressor.ts +++ b/packages/session-replay-browser/src/events/event-compressor.ts @@ -9,6 +9,7 @@ interface TaskQueue { sessionId: number; } +const DEFAULT_TIMEOUT = 2000; export class EventCompressor { taskQueue: TaskQueue[] = []; isProcessing = false; @@ -16,6 +17,7 @@ export class EventCompressor { config: SessionReplayJoinedConfig; deviceId: string | undefined; canUseIdleCallback: boolean | undefined; + timeout: number; constructor( eventsManager: SessionReplayEventsManager<'replay' | 'interaction', string>, @@ -27,6 +29,7 @@ export class EventCompressor { this.eventsManager = eventsManager; this.config = config; this.deviceId = deviceId; + this.timeout = config.performanceConfig?.timeout || DEFAULT_TIMEOUT; } // Schedule processing during idle time @@ -37,7 +40,7 @@ export class EventCompressor { (idleDeadline) => { this.processQueue(idleDeadline); }, - { timeout: 2000 }, + { timeout: this.timeout }, ); } } @@ -69,20 +72,20 @@ export class EventCompressor { (idleDeadline) => { this.processQueue(idleDeadline); }, - { timeout: 2000 }, + { timeout: this.timeout }, ); } else { this.isProcessing = false; } } - compressEvents = (event: eventWithTime) => { + compressEvent = (event: eventWithTime) => { const packedEvent = pack(event); return JSON.stringify(packedEvent); }; public addCompressedEvent = (event: eventWithTime, sessionId: number) => { - const compressedEvent = this.compressEvents(event); + const compressedEvent = this.compressEvent(event); if (this.eventsManager && this.deviceId) { this.eventsManager.addEvent({ diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index a0022c9ef..c644fff06 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -315,7 +315,6 @@ export class SessionReplay implements AmplitudeSessionReplay { recordEvents() { const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; - // const canDelayCompression = globalScope && 'requestIdleCallback' in globalScope; if (!shouldRecord || !sessionId || !this.config) { return; } diff --git a/packages/session-replay-browser/test/event-compressor.test.ts b/packages/session-replay-browser/test/event-compressor.test.ts index 5a5a785d6..d22dd8c1b 100644 --- a/packages/session-replay-browser/test/event-compressor.test.ts +++ b/packages/session-replay-browser/test/event-compressor.test.ts @@ -39,6 +39,7 @@ describe('EventCompressor', () => { sampleRate: 1, performanceConfig: { enabled: true, + timeout: 2000, }, }); From 2d23e155c9fad0e0afd701ef01f155005e789dd2 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Tue, 24 Sep 2024 09:02:30 -0700 Subject: [PATCH 8/8] chore(session replay): adding some debug statements --- packages/session-replay-browser/src/events/event-compressor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/session-replay-browser/src/events/event-compressor.ts b/packages/session-replay-browser/src/events/event-compressor.ts index bbd8c96c7..3c9945289 100644 --- a/packages/session-replay-browser/src/events/event-compressor.ts +++ b/packages/session-replay-browser/src/events/event-compressor.ts @@ -48,9 +48,11 @@ export class EventCompressor { // Add an event to the task queue if idle callback is supported or compress the event directly public enqueueEvent(event: eventWithTime, sessionId: number): void { if (this.canUseIdleCallback && this.config.performanceConfig?.enabled) { + this.config.loggerProvider.debug('Enqueuing event for processing during idle time.'); this.taskQueue.push({ event, sessionId }); this.scheduleIdleProcessing(); } else { + this.config.loggerProvider.debug('Processing event without idle callback.'); this.addCompressedEvent(event, sessionId); } }