Skip to content

Commit e11650c

Browse files
author
Kelly Wallach
committed
feat(session replay): store targeting match in remote config fetch class
1 parent afe118d commit e11650c

File tree

5 files changed

+244
-109
lines changed

5 files changed

+244
-109
lines changed

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

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getAnalyticsConnector, getGlobalScope } from '@amplitude/analytics-clie
22
import { Logger, returnWrapper } from '@amplitude/analytics-core';
33
import { Logger as ILogger } from '@amplitude/analytics-types';
44
import { pack, record } from '@amplitude/rrweb';
5-
import { TargetingParameters, evaluateTargeting } from '@amplitude/targeting';
5+
import { TargetingParameters } from '@amplitude/targeting';
66
import { createSessionReplayJoinedConfigGenerator } from './config/joined-config';
77
import { SessionReplayJoinedConfig, SessionReplayJoinedConfigGenerator } from './config/types';
88
import {
@@ -33,7 +33,6 @@ export class SessionReplay implements AmplitudeSessionReplay {
3333
sessionIDBStore: AmplitudeSessionReplaySessionIDBStore | undefined;
3434
loggerProvider: ILogger;
3535
recordCancelCallback: ReturnType<typeof record> | null = null;
36-
sessionTargetingMatch = false;
3736

3837
constructor() {
3938
this.loggerProvider = new Logger();
@@ -181,26 +180,11 @@ export class SessionReplay implements AmplitudeSessionReplay {
181180
return;
182181
}
183182

184-
try {
185-
const targetingConfig = await this.remoteConfigFetch.getTargetingConfig(this.identifiers.sessionId);
186-
console.log('targetingConfig', targetingConfig);
187-
if (targetingConfig && Object.keys(targetingConfig).length > 0) {
188-
const targetingResult = evaluateTargeting({
189-
flag: targetingConfig,
190-
sessionId: this.identifiers.sessionId,
191-
deviceId: this.getDeviceId(),
192-
...targetingParams,
193-
});
194-
this.sessionTargetingMatch =
195-
this.sessionTargetingMatch === false && targetingResult.sr_targeting_config.key === 'on';
196-
// todo, need to save this in idb too
197-
} else {
198-
this.sessionTargetingMatch = true;
199-
}
200-
} catch (err: unknown) {
201-
const knownError = err as Error;
202-
this.config.loggerProvider.warn(knownError.message);
203-
}
183+
await this.remoteConfigFetch.evaluateTargeting({
184+
sessionId: this.identifiers.sessionId,
185+
deviceId: this.getDeviceId(),
186+
...targetingParams,
187+
});
204188
};
205189

206190
stopRecordingAndSendEvents(sessionId?: number) {

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
<<<<<<< HEAD
21
import { AmplitudeReturn, ServerZone } from '@amplitude/analytics-types';
32
import { SessionReplayLocalConfig } from '../config/types';
4-
=======
5-
import { AmplitudeReturn, Config, LogLevel, Logger, ServerZone } from '@amplitude/analytics-types';
6-
import { TargetingFlag } from '@amplitude/targeting';
7-
>>>>>>> 69d3d21e (feat(session replay): introduce remote config fetch class)
83

94
export type Events = string[];
105

11-
export interface SessionReplayRemoteConfig {
12-
sr_targeting_config: TargetingFlag;
13-
}
14-
156
export interface SessionReplayDestination {
167
events: Events;
178
sequenceId: number;
@@ -67,14 +58,6 @@ export interface SessionReplayTrackDestination {
6758
flush: (useRetry: boolean) => Promise<void>;
6859
}
6960

70-
export interface SessionReplayRemoteConfigFetch {
71-
getRemoteConfig: (sessionId: number) => Promise<SessionReplayRemoteConfig | void>;
72-
getTargetingConfig: (sessionId: number) => Promise<TargetingFlag | void>;
73-
}
74-
export interface SessionReplayRemoteConfig {
75-
sr_targeting_config: TargetingFlag;
76-
}
77-
7861
export interface SessionReplayEventsManager {
7962
sendStoredEvents({ deviceId }: { deviceId: string }): Promise<void>;
8063
addEvent({ sessionId, event, deviceId }: { sessionId: number; event: string; deviceId: string }): void;

packages/session-replay-browser/test/remote-config-fetch.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Logger } from '@amplitude/analytics-types';
2+
import * as Targeting from '@amplitude/targeting';
23
import { SessionReplayConfig } from '../src/config';
34
import { SessionReplayRemoteConfigFetch } from '../src/remote-config-fetch';
45
import { SessionReplaySessionIDBStore } from '../src/session-idb-store';
@@ -7,6 +8,9 @@ import { flagConfig } from './flag-config-data';
78

89
type MockedLogger = jest.Mocked<Logger>;
910

11+
jest.mock('@amplitude/targeting');
12+
type MockedTargeting = jest.Mocked<typeof import('@amplitude/targeting')>;
13+
1014
const mockRemoteConfig: SessionReplayRemoteConfig = {
1115
sr_targeting_config: flagConfig,
1216
};
@@ -21,6 +25,7 @@ async function runScheduleTimers() {
2125
}
2226

2327
describe('SessionReplayRemoteConfigFetch', () => {
28+
const { evaluateTargeting } = Targeting as MockedTargeting;
2429
let originalFetch: typeof global.fetch;
2530
const mockLoggerProvider: MockedLogger = {
2631
error: jest.fn(),
@@ -309,4 +314,82 @@ describe('SessionReplayRemoteConfigFetch', () => {
309314
});
310315
});
311316
});
317+
318+
describe('evaluateTargeting', () => {
319+
let remoteConfigFetch: SessionReplayRemoteConfigFetch;
320+
let storeTargetingMatchForSessionMock: jest.Mock;
321+
let getTargetingMatchForSessionMock: jest.Mock;
322+
let getTargetingConfigMock: jest.Mock;
323+
beforeEach(() => {
324+
storeTargetingMatchForSessionMock = jest.fn();
325+
getTargetingMatchForSessionMock = jest.fn();
326+
getTargetingConfigMock = jest.fn();
327+
sessionIDBStore.storeRemoteConfigForSession = storeTargetingMatchForSessionMock;
328+
sessionIDBStore.getTargetingMatchForSession = getTargetingMatchForSessionMock;
329+
remoteConfigFetch = new SessionReplayRemoteConfigFetch({ config, sessionIDBStore });
330+
});
331+
test('should do nothing if sessionTargetingMatch is true', async () => {
332+
remoteConfigFetch.sessionTargetingMatch = true;
333+
await remoteConfigFetch.evaluateTargeting({ sessionId: 123 });
334+
expect(getTargetingMatchForSessionMock).not.toHaveBeenCalled();
335+
});
336+
test('should return a true match from IndexedDB', async () => {
337+
remoteConfigFetch.sessionTargetingMatch = false;
338+
getTargetingMatchForSessionMock.mockResolvedValueOnce(true);
339+
await remoteConfigFetch.evaluateTargeting({ sessionId: 123 });
340+
expect(getTargetingMatchForSessionMock).toHaveBeenCalled();
341+
expect(getTargetingConfigMock).not.toHaveBeenCalled();
342+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(true);
343+
});
344+
345+
test('should fetch remote config and use it to determine targeting match', async () => {
346+
remoteConfigFetch.sessionTargetingMatch = false;
347+
getTargetingConfigMock.mockResolvedValueOnce(flagConfig);
348+
remoteConfigFetch.getTargetingConfig = getTargetingConfigMock;
349+
evaluateTargeting.mockReturnValueOnce({
350+
sr_targeting_config: {
351+
key: 'on',
352+
},
353+
});
354+
const mockUserProperties = {
355+
country: 'US',
356+
city: 'San Francisco',
357+
};
358+
await remoteConfigFetch.evaluateTargeting({
359+
sessionId: 123,
360+
deviceId: '1a2b3c',
361+
userProperties: mockUserProperties,
362+
});
363+
expect(evaluateTargeting).toHaveBeenCalledWith({
364+
flag: flagConfig,
365+
sessionId: 123,
366+
deviceId: '1a2b3c',
367+
userProperties: mockUserProperties,
368+
});
369+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(true);
370+
});
371+
test('should set sessionTargetingMatch to true if no targeting config returned', async () => {
372+
getTargetingConfigMock = jest.fn().mockResolvedValue(undefined);
373+
remoteConfigFetch.getTargetingConfig = getTargetingConfigMock;
374+
await remoteConfigFetch.evaluateTargeting({ sessionId: 123 });
375+
expect(evaluateTargeting).not.toHaveBeenCalled();
376+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(true);
377+
});
378+
test('should set sessionTargetingMatch to true if targeting config returned as empty object', async () => {
379+
getTargetingConfigMock = jest.fn().mockResolvedValue({});
380+
remoteConfigFetch.getTargetingConfig = getTargetingConfigMock;
381+
await remoteConfigFetch.evaluateTargeting({ sessionId: 123 });
382+
expect(evaluateTargeting).not.toHaveBeenCalled();
383+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(true);
384+
});
385+
test('should not update sessionTargetingMatch getTargetingConfig throws error', async () => {
386+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(false);
387+
getTargetingConfigMock = jest.fn().mockImplementation(() => {
388+
throw new Error();
389+
});
390+
await remoteConfigFetch.evaluateTargeting({ sessionId: 123 });
391+
expect(evaluateTargeting).not.toHaveBeenCalled();
392+
expect(remoteConfigFetch.sessionTargetingMatch).toBe(false);
393+
});
394+
});
312395
});

packages/session-replay-browser/test/session-idb-store.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,4 +540,149 @@ describe('SessionReplaySessionIDBStore', () => {
540540
});
541541
});
542542
});
543+
describe('getTargetingMatchForSession', () => {
544+
test('should return the remote config from idb store', async () => {
545+
const mockStore: IDBStore = {
546+
123: {
547+
currentSequenceId: 3,
548+
remoteConfig: mockRemoteConfig,
549+
targetingMatch: true,
550+
sessionSequences: {
551+
3: {
552+
events: [mockEventString],
553+
status: RecordingStatus.RECORDING,
554+
},
555+
},
556+
},
557+
};
558+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
559+
get.mockResolvedValueOnce(mockStore);
560+
const targetingMatch = await eventsStorage.getTargetingMatchForSession(123);
561+
expect(targetingMatch).toEqual(true);
562+
});
563+
test('should handle an undefined store', async () => {
564+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
565+
get.mockResolvedValueOnce(undefined);
566+
const targetingMatch = await eventsStorage.getTargetingMatchForSession(123);
567+
expect(targetingMatch).toEqual(undefined);
568+
});
569+
test('should catch errors', async () => {
570+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
571+
get.mockImplementationOnce(() => Promise.reject('error'));
572+
await eventsStorage.getTargetingMatchForSession(123);
573+
// eslint-disable-next-line @typescript-eslint/unbound-method
574+
expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1);
575+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
576+
expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual(
577+
'Failed to store session replay events in IndexedDB: error',
578+
);
579+
});
580+
});
581+
582+
describe('storeTargetingMatchForSession', () => {
583+
test('should set the targeting match on the session map', async () => {
584+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
585+
const mockIDBStore: IDBStore = {
586+
123: {
587+
currentSequenceId: 2,
588+
sessionSequences: {
589+
2: {
590+
events: [mockEventString],
591+
status: RecordingStatus.RECORDING,
592+
},
593+
},
594+
},
595+
456: {
596+
currentSequenceId: 1,
597+
sessionSequences: {
598+
1: {
599+
events: [],
600+
status: RecordingStatus.SENT,
601+
},
602+
},
603+
},
604+
};
605+
await eventsStorage.storeTargetingMatchForSession(123, true);
606+
607+
expect(update).toHaveBeenCalledTimes(1);
608+
expect(update.mock.calls[0][1](mockIDBStore)).toEqual({
609+
123: {
610+
currentSequenceId: 2,
611+
targetingMatch: true,
612+
sessionSequences: {
613+
2: {
614+
events: [mockEventString],
615+
status: RecordingStatus.RECORDING,
616+
},
617+
},
618+
},
619+
456: {
620+
currentSequenceId: 1,
621+
sessionSequences: {
622+
1: {
623+
events: [],
624+
status: RecordingStatus.SENT,
625+
},
626+
},
627+
},
628+
});
629+
});
630+
test('should add a new entry if none exist for session id', async () => {
631+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
632+
const mockIDBStore: IDBStore = {
633+
123: {
634+
currentSequenceId: 2,
635+
sessionSequences: {
636+
2: {
637+
events: [],
638+
status: RecordingStatus.SENT,
639+
},
640+
},
641+
},
642+
};
643+
await eventsStorage.storeTargetingMatchForSession(456, true);
644+
645+
expect(update).toHaveBeenCalledTimes(1);
646+
expect(update.mock.calls[0][1](mockIDBStore)).toEqual({
647+
123: {
648+
currentSequenceId: 2,
649+
sessionSequences: {
650+
2: {
651+
events: [],
652+
status: RecordingStatus.SENT,
653+
},
654+
},
655+
},
656+
456: {
657+
currentSequenceId: 0,
658+
targetingMatch: true,
659+
sessionSequences: {},
660+
},
661+
});
662+
});
663+
test('should catch errors', async () => {
664+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
665+
update.mockImplementationOnce(() => Promise.reject('error'));
666+
await eventsStorage.storeTargetingMatchForSession(123, true);
667+
668+
// eslint-disable-next-line @typescript-eslint/unbound-method
669+
expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1);
670+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
671+
expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual(
672+
'Failed to store session replay events in IndexedDB: error',
673+
);
674+
});
675+
test('should handle an undefined store', async () => {
676+
const eventsStorage = new SessionReplaySessionIDBStore({ apiKey, loggerProvider: mockLoggerProvider });
677+
update.mockImplementationOnce(() => Promise.resolve());
678+
await eventsStorage.storeTargetingMatchForSession(456, true);
679+
expect(update.mock.calls[0][1](undefined)).toEqual({
680+
456: {
681+
currentSequenceId: 0,
682+
targetingMatch: true,
683+
sessionSequences: {},
684+
},
685+
});
686+
});
687+
});
543688
});

0 commit comments

Comments
 (0)