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

feat(session replay): delay compresion #885

Merged
merged 10 commits into from
Sep 24, 2024
39 changes: 33 additions & 6 deletions packages/session-replay-browser/src/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -309,6 +310,36 @@ export class SessionReplay implements AmplitudeSessionReplay {
return maskSelector as unknown as string;
}

compressEvents = (event: eventWithTime) => {
jxiwang marked this conversation as resolved.
Show resolved Hide resolved
const packedEvent = pack(event);
return JSON.stringify(packedEvent);
};

addCompressedEvents = (event: eventWithTime, sessionId: number) => {
this.loggerProvider.log('Compressing event for session replay: ', event);
jxiwang marked this conversation as resolved.
Show resolved Hide resolved
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) {
jxiwang marked this conversation as resolved.
Show resolved Hide resolved
requestIdleCallback(
() => {
this.loggerProvider.log('Adding event to idle callback queue: ', event);
jxiwang marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand All @@ -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:
Expand Down
62 changes: 56 additions & 6 deletions packages/session-replay-browser/test/session-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -862,23 +870,65 @@ 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;
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(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, '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,
});
Expand Down
Loading