Skip to content

Commit

Permalink
feat(session replay): performance config for requestIdleCallback
Browse files Browse the repository at this point in the history
  • Loading branch information
jxiwang committed Sep 24, 2024
1 parent 77bba12 commit df7983e
Show file tree
Hide file tree
Showing 6 changed files with 24 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ export interface SessionReplayPrivacyConfig {
maskSelector?: string[];
unmaskSelector?: string[];
}

export interface SessionReplayPerformanceConfig {
enabled: boolean;
}

export interface SessionReplayOptions {
sampleRate?: number;
privacyConfig?: SessionReplayPrivacyConfig;
debugMode?: boolean;
forceSessionTracking?: boolean;
configEndpointUrl?: string;
shouldInlineStylesheet?: boolean;
performanceConfig?: SessionReplayPerformanceConfig;
}
3 changes: 3 additions & 0 deletions packages/session-replay-browser/src/config/local-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SessionReplayLocalConfig as ISessionReplayLocalConfig,
InteractionConfig,
PrivacyConfig,
SessionReplayPerformanceConfig,
SessionReplayVersion,
} from './types';

Expand All @@ -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();
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/session-replay-browser/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface SessionReplayLocalConfig extends Config {
configEndpointUrl?: string;
shouldInlineStylesheet?: boolean;
version?: SessionReplayVersion;
performanceConfig?: SessionReplayPerformanceConfig;
}

export interface SessionReplayJoinedConfig extends SessionReplayLocalConfig {
Expand All @@ -72,4 +73,8 @@ export interface SessionReplayVersion {
type: SessionReplayType;
}

export interface SessionReplayPerformanceConfig {
enabled: boolean;
}

export type SessionReplayType = 'standalone' | 'plugin' | 'segment';
43 changes: 9 additions & 34 deletions packages/session-replay-browser/src/session-replay.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -43,6 +43,7 @@ export class SessionReplay implements AmplitudeSessionReplay {
loggerProvider: ILogger;
recordCancelCallback: ReturnType<typeof record> | null = null;
eventCount = 0;
eventCompressor: EventCompressor | undefined;

// Visible for testing
pageLeaveFns: PageLeaveFn[] = [];
Expand Down Expand Up @@ -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.');

Expand Down Expand Up @@ -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;
}
Expand All @@ -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: {
Expand Down
67 changes: 0 additions & 67 deletions packages/session-replay-browser/test/session-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit df7983e

Please sign in to comment.