Skip to content

Commit d376a62

Browse files
author
Kelly Wallach
authored
feat(session replay): correct timing of replay capture, and use targeting config or sampling config (#841)
2 parents a58c812 + 167750a commit d376a62

File tree

14 files changed

+368
-668
lines changed

14 files changed

+368
-668
lines changed

packages/plugin-session-replay-browser/src/session-replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class SessionReplayEnrichmentPlugin implements EnrichmentPlugin {
4848
if (event.event_type === SpecialEventType.IDENTIFY) {
4949
userProperties = parseUserProperties(event);
5050
}
51-
await sessionReplay.evaluateTargetingAndRecord({ event, userProperties });
51+
await sessionReplay.evaluateTargetingAndCapture({ event, userProperties });
5252
const sessionRecordingProperties = sessionReplay.getSessionReplayProperties();
5353
event.event_properties = {
5454
...event.event_properties,

packages/plugin-session-replay-browser/test/session-replay.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type MockedLogger = jest.Mocked<Logger>;
2020
type MockedBrowserClient = jest.Mocked<BrowserClient>;
2121

2222
describe('SessionReplayPlugin', () => {
23-
const { init, setSessionId, getSessionReplayProperties, evaluateTargetingAndRecord, flush, shutdown, getSessionId } =
23+
const { init, setSessionId, getSessionReplayProperties, evaluateTargetingAndCapture, flush, shutdown, getSessionId } =
2424
sessionReplayBrowser as MockedSessionReplayBrowser;
2525
const mockLoggerProvider: MockedLogger = {
2626
error: jest.fn(),
@@ -334,7 +334,7 @@ describe('SessionReplayPlugin', () => {
334334
await sessionReplayEnrichmentPlugin.setup(mockConfig);
335335
await sessionReplayEnrichmentPlugin.execute(event);
336336

337-
expect(evaluateTargetingAndRecord).toHaveBeenCalledWith({
337+
expect(evaluateTargetingAndCapture).toHaveBeenCalledWith({
338338
event: event,
339339
userProperties: undefined,
340340
});
@@ -360,7 +360,7 @@ describe('SessionReplayPlugin', () => {
360360
await sessionReplayEnrichmentPlugin.setup(mockConfig);
361361
await sessionReplayEnrichmentPlugin.execute(event);
362362

363-
expect(evaluateTargetingAndRecord).toHaveBeenCalledWith({
363+
expect(evaluateTargetingAndCapture).toHaveBeenCalledWith({
364364
event: event,
365365
userProperties: {
366366
plan_id: 'free',

packages/session-replay-browser/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ sessionReplay.init(API_KEY, {
5050
### 3. Evaluate targeting (optional)
5151
Any event that occurs within the span of a session replay must be passed to the SDK to evaluate against targeting conditions. This should be done *before* step 4, getting the event properties. If you are not using the targeting condition logic provided via the Amplitude UI, this step is not required.
5252
```typescript
53-
const sessionTargetingMatch = sessionReplay.evaluateTargetingAndRecord({ event: {
53+
const sessionTargetingMatch = sessionReplay.evaluateTargetingAndCapture({ event: {
5454
event_type: EVENT_NAME,
5555
time: EVENT_TIMESTAMP,
5656
event_properties: eventProperties

packages/session-replay-browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"@amplitude/analytics-remote-config": "^0.3.4",
4444
"@amplitude/analytics-types": ">=1 <3",
4545
"@amplitude/rrweb": "2.0.0-alpha.19",
46-
"@amplitude/targeting": "0.1.1",
46+
"@amplitude/targeting": "0.2.0",
4747
"idb": "^8.0.0",
4848
"tslib": "^2.4.1"
4949
},

packages/session-replay-browser/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export const {
33
init,
44
setSessionId,
55
getSessionId,
6-
evaluateTargetingAndRecord,
6+
evaluateTargetingAndCapture,
77
getSessionReplayProperties,
88
flush,
99
shutdown,

packages/session-replay-browser/src/session-replay-factory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ const createInstance: () => AmplitudeSessionReplay = () => {
1717
const sessionReplay = new SessionReplay();
1818
return {
1919
init: debugWrapper(sessionReplay.init.bind(sessionReplay), 'init', getLogConfig(sessionReplay)),
20-
evaluateTargetingAndRecord: debugWrapper(
21-
sessionReplay.evaluateTargetingAndRecord.bind(sessionReplay),
20+
evaluateTargetingAndCapture: debugWrapper(
21+
sessionReplay.evaluateTargetingAndCapture.bind(sessionReplay),
2222
'evaluateTargetingAndRecord',
2323
getLogConfig(sessionReplay),
2424
),

packages/session-replay-browser/src/session-replay.ts

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,17 @@ export class SessionReplay implements AmplitudeSessionReplay {
128128

129129
this.teardownEventListeners(false);
130130

131-
await this.evaluateTargetingAndRecord({ userProperties: options.userProperties });
131+
this.stopRecordingEvents();
132+
await this.evaluateTargetingAndCapture({ userProperties: options.userProperties });
132133
}
133134

134135
setSessionId(sessionId: number, deviceId?: string, options?: { userProperties?: { [key: string]: any } }) {
135136
return returnWrapper(this.asyncSetSessionId(sessionId, deviceId, options));
136137
}
137138

138139
async asyncSetSessionId(sessionId: number, deviceId?: string, options?: { userProperties?: { [key: string]: any } }) {
140+
this.stopRecordingEvents();
141+
139142
const previousSessionId = this.identifiers && this.identifiers.sessionId;
140143
if (previousSessionId) {
141144
this.sendEvents(previousSessionId);
@@ -152,7 +155,7 @@ export class SessionReplay implements AmplitudeSessionReplay {
152155
if (this.joinedConfigGenerator && previousSessionId) {
153156
this.config = await this.joinedConfigGenerator.generateJoinedConfig(this.identifiers.sessionId);
154157
}
155-
await this.evaluateTargetingAndRecord({ userProperties: options?.userProperties });
158+
await this.evaluateTargetingAndCapture({ userProperties: options?.userProperties });
156159
}
157160

158161
getSessionReplayDebugPropertyValue() {
@@ -171,7 +174,7 @@ export class SessionReplay implements AmplitudeSessionReplay {
171174
return {};
172175
}
173176

174-
const shouldRecord = this.getShouldRecord();
177+
const shouldRecord = this.getShouldCapture();
175178

176179
if (shouldRecord) {
177180
const eventProperties: { [key: string]: string | null } = {
@@ -193,9 +196,8 @@ export class SessionReplay implements AmplitudeSessionReplay {
193196
focusListener = () => {
194197
// Restart recording on focus to ensure that when user
195198
// switches tabs, we take a full snapshot
196-
if (this.sessionTargetingMatch) {
197-
this.recordEvents();
198-
}
199+
this.stopRecordingEvents();
200+
this.captureEvents();
199201
};
200202

201203
/**
@@ -209,44 +211,35 @@ export class SessionReplay implements AmplitudeSessionReplay {
209211
});
210212
};
211213

212-
evaluateTargetingAndRecord = async (targetingParams?: Pick<TargetingParameters, 'event' | 'userProperties'>) => {
214+
evaluateTargetingAndCapture = async (targetingParams?: Pick<TargetingParameters, 'event' | 'userProperties'>) => {
213215
if (!this.identifiers || !this.identifiers.sessionId || !this.config) {
214-
if (!this.identifiers?.sessionId) {
215-
this.loggerProvider.warn('Session ID has not been set, cannot evaluate targeting for Session Replay.');
216+
if (this.identifiers && !this.identifiers.sessionId) {
217+
this.loggerProvider.log('Session ID has not been set yet, cannot evaluate targeting for Session Replay.');
216218
} else {
217219
this.loggerProvider.warn('Session replay init has not been called, cannot evaluate targeting.');
218220
}
219-
return false;
220-
}
221-
// Return early if a targeting match has already been made
222-
if (this.sessionTargetingMatch) {
223-
if (!this.recordCancelCallback) {
224-
this.loggerProvider.log('Session replay capture beginning due to targeting match.');
225-
this.recordEvents();
226-
}
227-
return this.sessionTargetingMatch;
228-
}
229-
230-
let eventForTargeting = targetingParams?.event;
231-
if (
232-
eventForTargeting &&
233-
Object.values(SpecialEventType).includes(eventForTargeting.event_type as SpecialEventType)
234-
) {
235-
eventForTargeting = undefined;
221+
return;
236222
}
237223

238-
this.sessionTargetingMatch = await evaluateTargetingAndStore({
239-
sessionId: this.identifiers.sessionId,
240-
config: this.config,
241-
targetingParams: { userProperties: targetingParams?.userProperties, event: eventForTargeting },
242-
});
224+
if (this.config.targetingConfig && !this.sessionTargetingMatch) {
225+
let eventForTargeting = targetingParams?.event;
226+
if (
227+
eventForTargeting &&
228+
Object.values(SpecialEventType).includes(eventForTargeting.event_type as SpecialEventType)
229+
) {
230+
eventForTargeting = undefined;
231+
}
243232

244-
if (this.sessionTargetingMatch) {
245-
this.loggerProvider.log('Session replay capture beginning due to targeting match.');
246-
this.recordEvents();
233+
this.sessionTargetingMatch = await evaluateTargetingAndStore({
234+
sessionId: this.identifiers.sessionId,
235+
targetingConfig: this.config.targetingConfig,
236+
loggerProvider: this.loggerProvider,
237+
apiKey: this.config.apiKey,
238+
targetingParams: { userProperties: targetingParams?.userProperties, event: eventForTargeting },
239+
});
247240
}
248241

249-
return this.sessionTargetingMatch;
242+
this.captureEvents();
250243
};
251244

252245
sendEvents(sessionId?: number) {
@@ -268,9 +261,9 @@ export class SessionReplay implements AmplitudeSessionReplay {
268261
return identityStoreOptOut !== undefined ? identityStoreOptOut : this.config?.optOut;
269262
}
270263

271-
getShouldRecord() {
264+
getShouldCapture() {
272265
if (!this.identifiers || !this.config || !this.identifiers.sessionId) {
273-
this.loggerProvider.warn(`Session is not being recorded due to lack of config, please call sessionReplay.init.`);
266+
this.loggerProvider.warn(`Session is not being captured due to lack of config, please call sessionReplay.init.`);
274267
return false;
275268
}
276269
if (!this.config.captureEnabled) {
@@ -281,14 +274,35 @@ export class SessionReplay implements AmplitudeSessionReplay {
281274
}
282275

283276
if (this.shouldOptOut()) {
284-
this.loggerProvider.log(`Opting session ${this.identifiers.sessionId} out of recording due to optOut config.`);
277+
this.loggerProvider.log(
278+
`Opting session ${this.identifiers.sessionId} out of replay capture due to optOut config.`,
279+
);
285280
return false;
286281
}
287282

288-
const isInSample = isSessionInSample(this.identifiers.sessionId, this.config.sampleRate);
289-
if (!isInSample) {
290-
this.loggerProvider.log(`Opting session ${this.identifiers.sessionId} out of recording due to sample rate.`);
291-
return false;
283+
// If targetingConfig exists, we'll use the sessionTargetingMatch to determine whether to record
284+
// Otherwise, we'll evaluate the session against the overall sample rate
285+
if (this.config.targetingConfig) {
286+
if (!this.sessionTargetingMatch) {
287+
this.loggerProvider.log(
288+
`Not capturing replays for session ${this.identifiers.sessionId} due to not matching targeting conditions.`,
289+
);
290+
return false;
291+
}
292+
this.loggerProvider.log(
293+
`Capturing replays for session ${this.identifiers.sessionId} due to matching targeting conditions.`,
294+
);
295+
} else {
296+
const isInSample = isSessionInSample(this.identifiers.sessionId, this.config.sampleRate);
297+
if (!isInSample) {
298+
this.loggerProvider.log(
299+
`Opting session ${this.identifiers.sessionId} out of replay capture due to sample rate.`,
300+
);
301+
return false;
302+
}
303+
this.loggerProvider.log(
304+
`Capturing replays for session ${this.identifiers.sessionId} due to inclusion in sample rate.`,
305+
);
292306
}
293307

294308
return true;
@@ -318,20 +332,25 @@ export class SessionReplay implements AmplitudeSessionReplay {
318332
return maskSelector as unknown as string;
319333
}
320334

321-
recordEvents() {
322-
const shouldRecord = this.getShouldRecord();
323-
const sessionId = this.identifiers?.sessionId;
335+
captureEvents() {
336+
if (this.recordCancelCallback) {
337+
this.loggerProvider.debug('captureEvents method fired - Session Replay capture already in progress.');
338+
return;
339+
}
340+
341+
const shouldRecord = this.getShouldCapture();
342+
const sessionId = this.identifiers && this.identifiers.sessionId;
324343
if (!shouldRecord || !sessionId || !this.config) {
325344
return;
326345
}
327-
this.stopRecordingEvents();
346+
328347
const privacyConfig = this.config.privacyConfig;
329348

330349
this.loggerProvider.log('Session Replay capture beginning.');
331350
this.recordCancelCallback = record({
332351
emit: (event) => {
333352
if (this.shouldOptOut()) {
334-
this.loggerProvider.log(`Opting session ${sessionId} out of recording due to optOut config.`);
353+
this.loggerProvider.log(`Opting session ${sessionId} out of replay capture due to optOut config.`);
335354
this.stopRecordingEvents();
336355
this.sendEvents();
337356
return;
Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
import { TargetingParameters, evaluateTargeting as evaluateTargetingPackage } from '@amplitude/targeting';
2-
import { SessionReplayJoinedConfig } from 'src/config/types';
2+
import { TargetingConfig } from '../config/types';
3+
import { Logger } from '@amplitude/analytics-types';
34
import { targetingIDBStore } from './targeting-idb-store';
45

56
export const evaluateTargetingAndStore = async ({
67
sessionId,
7-
config,
8+
targetingConfig,
9+
loggerProvider,
10+
apiKey,
811
targetingParams,
912
}: {
1013
sessionId: number;
11-
config: SessionReplayJoinedConfig;
14+
targetingConfig: TargetingConfig;
15+
loggerProvider: Logger;
16+
apiKey: string;
1217
targetingParams?: Pick<TargetingParameters, 'event' | 'userProperties'>;
1318
}) => {
1419
await targetingIDBStore.clearStoreOfOldSessions({
15-
loggerProvider: config.loggerProvider,
16-
apiKey: config.apiKey,
20+
loggerProvider: loggerProvider,
21+
apiKey: apiKey,
1722
currentSessionId: sessionId,
1823
});
1924

2025
const idbTargetingMatch = await targetingIDBStore.getTargetingMatchForSession({
21-
loggerProvider: config.loggerProvider,
22-
apiKey: config.apiKey,
26+
loggerProvider: loggerProvider,
27+
apiKey: apiKey,
2328
sessionId: sessionId,
2429
});
2530
if (idbTargetingMatch === true) {
@@ -31,26 +36,26 @@ export const evaluateTargetingAndStore = async ({
3136
// so all users match targeting
3237
let sessionTargetingMatch = true;
3338
try {
34-
if (config.targetingConfig && Object.keys(config.targetingConfig).length) {
35-
const targetingResult = await evaluateTargetingPackage({
36-
...targetingParams,
37-
flag: config.targetingConfig,
38-
sessionId: sessionId,
39-
apiKey: config.apiKey,
40-
loggerProvider: config.loggerProvider,
41-
});
42-
39+
const targetingResult = await evaluateTargetingPackage({
40+
...targetingParams,
41+
flag: targetingConfig,
42+
sessionId: sessionId,
43+
apiKey: apiKey,
44+
loggerProvider: loggerProvider,
45+
});
46+
if (targetingResult && targetingResult.sr_targeting_config) {
4347
sessionTargetingMatch = targetingResult.sr_targeting_config.key === 'on';
4448
}
49+
4550
void targetingIDBStore.storeTargetingMatchForSession({
46-
loggerProvider: config.loggerProvider,
47-
apiKey: config.apiKey,
51+
loggerProvider: loggerProvider,
52+
apiKey: apiKey,
4853
sessionId: sessionId,
4954
targetingMatch: sessionTargetingMatch,
5055
});
5156
} catch (err: unknown) {
5257
const knownError = err as Error;
53-
config.loggerProvider.warn(knownError.message);
58+
loggerProvider.warn(knownError.message);
5459
}
5560
return sessionTargetingMatch;
5661
};

packages/session-replay-browser/src/typings/session-replay.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ export interface AmplitudeSessionReplay {
7979
) => AmplitudeReturn<void>;
8080
getSessionId: () => number | undefined;
8181
getSessionReplayProperties: () => { [key: string]: boolean | string | null };
82-
evaluateTargetingAndRecord: (
82+
evaluateTargetingAndCapture: (
8383
targetingParams?: Pick<TargetingParameters, 'event' | 'userProperties'>,
84-
) => Promise<boolean>;
84+
) => Promise<void>;
8585
flush: (useRetry: boolean) => Promise<void>;
8686
shutdown: () => void;
8787
}

0 commit comments

Comments
 (0)