diff --git a/packages/plugin-session-replay-browser/package.json b/packages/plugin-session-replay-browser/package.json index aeb0eab2b..7f46fe099 100644 --- a/packages/plugin-session-replay-browser/package.json +++ b/packages/plugin-session-replay-browser/package.json @@ -41,6 +41,8 @@ "dependencies": { "@amplitude/analytics-core": "^2.15.0", "@amplitude/session-replay-browser": "^1.25.3", + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", "idb-keyval": "^6.2.1", "tslib": "^2.4.1" }, diff --git a/packages/plugin-session-replay-browser/src/constants.ts b/packages/plugin-session-replay-browser/src/constants.ts index e69de29bb..2eba98982 100644 --- a/packages/plugin-session-replay-browser/src/constants.ts +++ b/packages/plugin-session-replay-browser/src/constants.ts @@ -0,0 +1,11 @@ +import { IdentifyOperation } from '@amplitude/analytics-types'; + +export const PROPERTY_ADD_OPERATIONS = [ + IdentifyOperation.SET, + IdentifyOperation.SET_ONCE, + IdentifyOperation.ADD, + IdentifyOperation.APPEND, + IdentifyOperation.PREPEND, + IdentifyOperation.POSTINSERT, + IdentifyOperation.POSTINSERT, +]; diff --git a/packages/plugin-session-replay-browser/src/helpers.ts b/packages/plugin-session-replay-browser/src/helpers.ts new file mode 100644 index 000000000..53b9c6643 --- /dev/null +++ b/packages/plugin-session-replay-browser/src/helpers.ts @@ -0,0 +1,22 @@ +import { Event, IdentifyOperation } from '@amplitude/analytics-types'; +import { PROPERTY_ADD_OPERATIONS } from './constants'; + +export const parseUserProperties = (event: Event) => { + if (!event.user_properties) { + return; + } + let userPropertiesObj = {}; + const userPropertyKeys = Object.keys(event.user_properties); + + userPropertyKeys.forEach((identifyKey) => { + if (PROPERTY_ADD_OPERATIONS.includes(identifyKey as IdentifyOperation)) { + const typedUserPropertiesOperation = + event.user_properties && (event.user_properties[identifyKey as IdentifyOperation] as Record); + userPropertiesObj = { + ...userPropertiesObj, + ...typedUserPropertiesOperation, + }; + } + }); + return userPropertiesObj; +}; diff --git a/packages/plugin-session-replay-browser/src/session-replay.ts b/packages/plugin-session-replay-browser/src/session-replay.ts index d5b0d5bdd..8841e4145 100644 --- a/packages/plugin-session-replay-browser/src/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/session-replay.ts @@ -1,8 +1,19 @@ -import { BrowserClient, BrowserConfig, EnrichmentPlugin, Event } from '@amplitude/analytics-core'; -import * as sessionReplay from '@amplitude/session-replay-browser'; +import { BrowserClient, BrowserConfig, EnrichmentPlugin, Event, SpecialEventType } from '@amplitude/analytics-core'; +import { + init, + setSessionId, + getSessionId, + getSessionReplayProperties, + flush, + shutdown, + evaluateTargetingAndCapture, + AmplitudeSessionReplay, + SessionReplayOptions as SessionReplayBrowserOptions, +} from '@amplitude/session-replay-browser'; +import { getAnalyticsConnector } from '@amplitude/analytics-client-common'; +import { parseUserProperties } from './helpers'; import { SessionReplayOptions } from './typings/session-replay'; import { VERSION } from './version'; -import { AmplitudeSessionReplay } from '@amplitude/session-replay-browser'; export class SessionReplayPlugin implements EnrichmentPlugin { static pluginName = '@amplitude/plugin-session-replay-browser'; @@ -13,14 +24,15 @@ export class SessionReplayPlugin implements EnrichmentPlugin { + describe('parseUserProperties', () => { + test('should return undefined when event has no user_properties', () => { + const event: Event = { + event_type: 'test_event', + }; + + const result = parseUserProperties(event); + expect(result).toBeUndefined(); + }); + + test('should parse SET operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.SET]: { + name: 'John', + age: 30, + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + name: 'John', + age: 30, + }); + }); + + test('should parse SET_ONCE operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.SET_ONCE]: { + initial_signup: '2023-01-01', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + initial_signup: '2023-01-01', + }); + }); + + test('should parse ADD operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.ADD]: { + score: 100, + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + score: 100, + }); + }); + + test('should parse APPEND operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.APPEND]: { + tags: 'new_tag', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + tags: 'new_tag', + }); + }); + + test('should parse PREPEND operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.PREPEND]: { + history: 'first_item', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + history: 'first_item', + }); + }); + + test('should ignore PREINSERT operation (not in PROPERTY_ADD_OPERATIONS)', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.PREINSERT]: { + list: 'item', + }, + [IdentifyOperation.SET]: { + name: 'John', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + name: 'John', + }); + }); + + test('should parse POSTINSERT operation correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.POSTINSERT]: { + queue: 'new_item', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + queue: 'new_item', + }); + }); + + test('should parse multiple operations correctly', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.SET]: { + name: 'John', + age: 30, + }, + [IdentifyOperation.ADD]: { + score: 100, + }, + [IdentifyOperation.APPEND]: { + tags: 'premium', + }, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + name: 'John', + age: 30, + score: 100, + tags: 'premium', + }); + }); + + test('should ignore non-identify operations', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.SET]: { + name: 'John', + }, + [IdentifyOperation.UNSET]: ['old_property'], + [IdentifyOperation.CLEAR_ALL]: true, + custom_property: 'should_be_ignored', + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({ + name: 'John', + }); + }); + + test('should handle empty operations object', () => { + const event: Event = { + event_type: 'test_event', + user_properties: { + [IdentifyOperation.SET]: {}, + }, + }; + + const result = parseUserProperties(event); + expect(result).toEqual({}); + }); + }); +}); diff --git a/packages/plugin-session-replay-browser/test/session-replay.test.ts b/packages/plugin-session-replay-browser/test/session-replay.test.ts index 63ec1432c..c9caf2384 100644 --- a/packages/plugin-session-replay-browser/test/session-replay.test.ts +++ b/packages/plugin-session-replay-browser/test/session-replay.test.ts @@ -1,4 +1,12 @@ -import { BrowserClient, BrowserConfig, LogLevel, ILogger, Plugin, Event } from '@amplitude/analytics-core'; +import { + BrowserClient, + BrowserConfig, + LogLevel, + ILogger, + Plugin, + Event, + SpecialEventType, +} from '@amplitude/analytics-core'; import * as sessionReplayBrowser from '@amplitude/session-replay-browser'; import { SessionReplayPlugin, sessionReplayPlugin } from '../src/session-replay'; import { VERSION } from '../src/version'; @@ -8,11 +16,10 @@ jest.mock('@amplitude/session-replay-browser'); type MockedSessionReplayBrowser = jest.Mocked; type MockedLogger = jest.Mocked; - type MockedBrowserClient = jest.Mocked; describe('SessionReplayPlugin', () => { - const { init, setSessionId, getSessionReplayProperties, shutdown, getSessionId } = + const { init, setSessionId, getSessionReplayProperties, shutdown, getSessionId, evaluateTargetingAndCapture } = sessionReplayBrowser as MockedSessionReplayBrowser; const mockLoggerProviderDebug = jest.fn(); const mockLoggerProvider: MockedLogger = { @@ -103,8 +110,31 @@ describe('SessionReplayPlugin', () => { expect(init).toHaveBeenCalledWith('static_key', expect.objectContaining({ deviceId: customDeviceId })); }); + test('should handle errors during init', async () => { + const sessionReplay = new SessionReplayPlugin(); + init.mockReturnValue({ + promise: Promise.reject(new Error('Mock init error')), + }); + + await sessionReplay.setup?.(mockConfig, mockAmplitude); + + expect(mockLoggerProvider['error']).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider['error'].mock.calls[0][0]).toBe( + 'Session Replay: Failed to initialize due to Mock init error', + ); + }); + + test('should handle setup successfully with valid config', async () => { + const sessionReplay = new SessionReplayPlugin(); + + await sessionReplay.setup?.(mockConfig, mockAmplitude); + + expect(init).toHaveBeenCalled(); + expect(sessionReplay.config).toBeDefined(); + }); + describe('defaultTracking', () => { - test('should not change defaultTracking forceSessionTracking is not defined', async () => { + test('should not change defaultTracking when forceSessionTracking is not defined', async () => { const sessionReplay = new SessionReplayPlugin(); await sessionReplay.setup?.( { @@ -201,25 +231,26 @@ describe('SessionReplayPlugin', () => { await sessionReplay.setup?.(mockConfig, mockAmplitude); expect(init).toHaveBeenCalledTimes(1); - expect(init.mock.calls[0][0]).toEqual(mockConfig.apiKey); - expect(init.mock.calls[0][1]).toEqual({ - deviceId: mockConfig.deviceId, - flushMaxRetries: mockConfig.flushMaxRetries, - logLevel: mockConfig.logLevel, - loggerProvider: mockConfig.loggerProvider, - optOut: mockConfig.optOut, - sampleRate: 0.4, - serverZone: mockConfig.serverZone, - sessionId: mockConfig.sessionId, - privacyConfig: { - blockSelector: ['#id'], - }, - version: { - type: 'plugin', - version: VERSION, - }, - }); + expect(init.mock.calls[0][1]).toEqual( + expect.objectContaining({ + deviceId: mockConfig.deviceId, + flushMaxRetries: mockConfig.flushMaxRetries, + logLevel: mockConfig.logLevel, + loggerProvider: mockConfig.loggerProvider, + optOut: mockConfig.optOut, + sampleRate: 0.4, + serverZone: mockConfig.serverZone, + sessionId: mockConfig.sessionId, + privacyConfig: expect.objectContaining({ + blockSelector: ['#id'], + }) as object, + version: { + type: 'plugin', + version: VERSION, + }, + }), + ); }); test('should call initialize on session replay sdk with custom server urls', async () => { @@ -236,78 +267,30 @@ describe('SessionReplayPlugin', () => { }); await sessionReplay.setup?.(mockConfig, mockAmplitude); - expect(init).toHaveBeenCalledTimes(1); - - expect(init.mock.calls[0][0]).toEqual(mockConfig.apiKey); - expect(init.mock.calls[0][1]).toEqual({ - deviceId: mockConfig.deviceId, - flushMaxRetries: mockConfig.flushMaxRetries, - logLevel: mockConfig.logLevel, - loggerProvider: mockConfig.loggerProvider, - optOut: mockConfig.optOut, - sampleRate: 0.4, - serverZone: mockConfig.serverZone, - configServerUrl, - trackServerUrl, - sessionId: mockConfig.sessionId, - privacyConfig: { - blockSelector: ['#id'], - }, - version: { - type: 'plugin', - version: VERSION, - }, - }); - }); - - test.each([ - { - description: 'should call init with applyBackgroundColorToBlockedElements=true when provided value is true', - options: { applyBackgroundColorToBlockedElements: true }, - expectedValue: true, - }, - { - description: - 'should call init with applyBackgroundColorToBlockedElements=undefined when provided value is undefined', - options: { applyBackgroundColorToBlockedElements: undefined }, - expectedValue: undefined, - }, - { - description: 'should default applyBackgroundColorToBlockedElements=undefined when not provided', - options: {}, - expectedValue: undefined, - }, - { - description: 'should call init with applyBackgroundColorToBlockedElements=false when provided value is false', - options: { applyBackgroundColorToBlockedElements: false }, - expectedValue: false, - }, - ])('$description', async ({ options, expectedValue }) => { - const sessionReplay = new SessionReplayPlugin({ - ...options, - }); - - await sessionReplay.setup?.(mockConfig, mockAmplitude); - expect(init).toHaveBeenCalledTimes(1); expect(init.mock.calls[0][0]).toEqual(mockConfig.apiKey); expect(init.mock.calls[0][1]).toEqual( expect.objectContaining({ - applyBackgroundColorToBlockedElements: expectedValue, + deviceId: mockConfig.deviceId, + flushMaxRetries: mockConfig.flushMaxRetries, + logLevel: mockConfig.logLevel, + loggerProvider: mockConfig.loggerProvider, + optOut: mockConfig.optOut, + sampleRate: 0.4, + serverZone: mockConfig.serverZone, + configServerUrl, + trackServerUrl, + sessionId: mockConfig.sessionId, + privacyConfig: expect.objectContaining({ + blockSelector: ['#id'], + }) as object, + version: { + type: 'plugin', + version: VERSION, + }, }), ); }); - - // eslint-disable-next-line jest/expect-expect - test('should fail gracefully', async () => { - expect(async () => { - const sessionReplay = new SessionReplayPlugin(); - init.mockImplementation(() => { - throw new Error('Mock Error'); - }); - await sessionReplay.setup?.(mockConfig, mockAmplitude); - }).not.toThrow(); - }); }); describe('onSessionIdChanged', () => { @@ -344,18 +327,11 @@ describe('SessionReplayPlugin', () => { const sessionReplay = new SessionReplayPlugin({ deviceId: customDeviceId, }); - // First init() called await sessionReplay.setup?.(mockConfig, mockAmplitude); - // Second init() called await sessionReplay.onOptOutChanged?.(false); expect(init).toHaveBeenCalledTimes(2); expect(mockLoggerProviderDebug).toHaveBeenCalledWith('optOut is changed to false, calling sessionReplay.init().'); - expect(sessionReplay.config.serverUrl).toBe('url'); - expect(sessionReplay.config.flushMaxRetries).toBe(1); - expect(sessionReplay.config.flushQueueSize).toBe(0); - expect(sessionReplay.config.flushIntervalMillis).toBe(0); - expect(init).toHaveBeenCalledWith('static_key', expect.objectContaining({ deviceId: customDeviceId })); expect(init).toHaveBeenNthCalledWith(2, 'static_key', expect.objectContaining({ deviceId: customDeviceId })); }); @@ -385,7 +361,7 @@ describe('SessionReplayPlugin', () => { }); }); - test('should not add event property for for event with mismatching session id.', async () => { + test('should not add event property for event with mismatching session id', async () => { const sessionReplay = sessionReplayPlugin(); await sessionReplay.setup?.({ ...mockConfig }, mockAmplitude); getSessionReplayProperties.mockReturnValueOnce({ @@ -433,6 +409,27 @@ describe('SessionReplayPlugin', () => { expect(setSessionId).not.toHaveBeenCalled(); }); + test('should call evaluateTargetingAndCapture for IDENTIFY events', async () => { + const sessionReplay = new SessionReplayPlugin(); + await sessionReplay.setup?.({ ...mockConfig, sessionId: 123 }, mockAmplitude); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + evaluateTargetingAndCapture.mockResolvedValue(undefined); + + const event = { + event_type: SpecialEventType.IDENTIFY, + user_properties: { + $set: { name: 'John', age: 30 }, + }, + session_id: 123, + }; + await sessionReplay.execute?.(event); + + expect(evaluateTargetingAndCapture).toHaveBeenCalledWith({ + event, + userProperties: { name: 'John', age: 30 }, + }); + }); + test('should return original event in case of errors', async () => { const sessionReplay = sessionReplayPlugin(); await sessionReplay.setup?.({ ...mockConfig }, mockAmplitude); @@ -450,28 +447,10 @@ describe('SessionReplayPlugin', () => { const enrichedEvent = await sessionReplay.execute?.(event); expect(enrichedEvent).toEqual(event); - }); - }); - - describe('teardown', () => { - test('should call session replay teardown', async () => { - const sessionReplay = sessionReplayPlugin(); - await sessionReplay.setup?.(mockConfig, mockAmplitude); - await sessionReplay.teardown?.(); - expect(shutdown).toHaveBeenCalled(); - }); - - test('internal errors should not be thrown', async () => { - expect(async () => { - const sessionReplay = sessionReplayPlugin(); - await sessionReplay.setup?.(mockConfig, mockAmplitude); - - // Mock the shutdown function to throw an error - shutdown.mockImplementation(() => { - throw new Error('Mock shutdown error'); - }); - await sessionReplay.teardown?.(); - }).not.toThrow(); + expect(mockLoggerProvider['error']).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider['error'].mock.calls[0][0]).toBe( + 'Session Replay: Failed to enrich event due to Mock error', + ); }); test('should update the session id on any event when using custom session id', async () => { @@ -553,6 +532,33 @@ describe('SessionReplayPlugin', () => { }); }); + describe('teardown', () => { + test('should call session replay teardown', async () => { + const sessionReplay = sessionReplayPlugin(); + await sessionReplay.setup?.(mockConfig, mockAmplitude); + await sessionReplay.teardown?.(); + expect(shutdown).toHaveBeenCalled(); + }); + + test('should handle teardown errors gracefully', async () => { + const sessionReplay = sessionReplayPlugin(); + await sessionReplay.setup?.(mockConfig, mockAmplitude); + + shutdown.mockImplementation(() => { + throw new Error('Mock shutdown error'); + }); + + expect(async () => { + await sessionReplay.teardown?.(); + }).not.toThrow(); + + expect(mockLoggerProvider['error']).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider['error'].mock.calls[0][0]).toBe( + 'Session Replay: teardown failed due to Mock shutdown error', + ); + }); + }); + describe('getSessionReplayProperties', () => { test('should return session replay properties', async () => { const sessionReplay = sessionReplayPlugin() as SessionReplayPlugin; diff --git a/packages/session-replay-browser/README.md b/packages/session-replay-browser/README.md index 77cca4a8b..b56886751 100644 --- a/packages/session-replay-browser/README.md +++ b/packages/session-replay-browser/README.md @@ -47,7 +47,17 @@ sessionReplay.init(API_KEY, { }); ``` -### 3. Get session replay event properties +### 3. Evaluate targeting (optional) +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. +```typescript +const sessionTargetingMatch = sessionReplay.evaluateTargetingAndCapture({ event: { + event_type: EVENT_NAME, + time: EVENT_TIMESTAMP, + event_properties: eventProperties +} }); +``` + +### 4. Get session replay event properties Any event that occurs within the span of a session replay must be tagged with properties that signal to Amplitude to include it in the scope of the replay. The following shows an example of how to use the properties ```typescript const sessionReplayProperties = sessionReplay.getSessionReplayProperties(); @@ -57,7 +67,7 @@ track(EVENT_NAME, { }) ``` -### 4. Update session id +### 5. Update session id Any time that the session id for the user changes, the session replay SDK must be notified of that change. Update the session id via the following method: ```typescript sessionReplay.setSessionId(UNIX_TIMESTAMP) @@ -67,7 +77,7 @@ You can optionally pass a new device id as a second argument as well: sessionReplay.setSessionId(UNIX_TIMESTAMP, deviceId) ``` -### 5. Shutdown (optional) +### 6. Shutdown (optional) If at any point you would like to discontinue collection of session replays, for example in a part of your application where you would not like sessions to be collected, you can use the following method to stop collection and remove collection event listeners. ```typescript sessionReplay.shutdown() diff --git a/packages/session-replay-browser/package.json b/packages/session-replay-browser/package.json index b16492fa6..1974c7192 100644 --- a/packages/session-replay-browser/package.json +++ b/packages/session-replay-browser/package.json @@ -48,6 +48,9 @@ "@amplitude/rrweb-utils": "2.0.0-alpha.30", "@rollup/plugin-replace": "^6.0.1", "idb": "8.0.0", + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "@amplitude/targeting": "0.2.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/session-replay-browser/rollup.config.js b/packages/session-replay-browser/rollup.config.js index 5b3cdda11..123d8fb9b 100644 --- a/packages/session-replay-browser/rollup.config.js +++ b/packages/session-replay-browser/rollup.config.js @@ -20,6 +20,7 @@ const esmConfig = { chunkFileNames: '[name]-min.js', manualChunks: { 'console-plugin': ['@amplitude/rrweb-plugin-console-record'], + 'targeting': ['@amplitude/targeting'], 'rrweb-record': ['@amplitude/rrweb-record'] } }, diff --git a/packages/session-replay-browser/scripts/publish/upload-to-s3.js b/packages/session-replay-browser/scripts/publish/upload-to-s3.js index 2b18deec1..3befa24e2 100644 --- a/packages/session-replay-browser/scripts/publish/upload-to-s3.js +++ b/packages/session-replay-browser/scripts/publish/upload-to-s3.js @@ -9,7 +9,8 @@ const files = [ 'session-replay-browser-esm.js.gz', // ESM version 'session-replay-browser-min.js.gz', // IIFE version 'console-plugin-min.js.gz', // Console plugin chunk - 'rrweb-record-min.js.gz' // RRWeb record chunk + 'rrweb-record-min.js.gz', // RRWeb record chunk + 'targeting-min.js.gz' // Targeting chunk ]; let deployedCount = 0; diff --git a/packages/session-replay-browser/src/config/joined-config.ts b/packages/session-replay-browser/src/config/joined-config.ts index d1a1eebec..98e858b9f 100644 --- a/packages/session-replay-browser/src/config/joined-config.ts +++ b/packages/session-replay-browser/src/config/joined-config.ts @@ -60,6 +60,7 @@ export class SessionReplayJoinedConfigGenerator { if (namespaceConfig) { const samplingConfig = namespaceConfig.sr_sampling_config; const privacyConfig = namespaceConfig.sr_privacy_config; + const targetingConfig = namespaceConfig.sr_targeting_config; const ugcFilterRules = config.interactionConfig?.ugcFilterRules; // This is intentionally forced to only be set through the remote config. @@ -71,7 +72,7 @@ export class SessionReplayJoinedConfigGenerator { // This is intentionally forced to only be set through the remote config. config.loggingConfig = namespaceConfig.sr_logging_config; - if (samplingConfig || privacyConfig) { + if (samplingConfig || privacyConfig || targetingConfig) { remoteConfig = {}; if (samplingConfig) { remoteConfig.sr_sampling_config = samplingConfig; @@ -79,6 +80,9 @@ export class SessionReplayJoinedConfigGenerator { if (privacyConfig) { remoteConfig.sr_privacy_config = privacyConfig; } + if (targetingConfig) { + remoteConfig.sr_targeting_config = targetingConfig; + } } } } catch (err: unknown) { @@ -95,7 +99,11 @@ export class SessionReplayJoinedConfigGenerator { }; } - const { sr_sampling_config: samplingConfig, sr_privacy_config: remotePrivacyConfig } = remoteConfig; + const { + sr_sampling_config: samplingConfig, + sr_privacy_config: remotePrivacyConfig, + sr_targeting_config: targetingConfig, + } = remoteConfig; if (samplingConfig && Object.keys(samplingConfig).length > 0) { if (Object.prototype.hasOwnProperty.call(samplingConfig, 'capture_enabled')) { config.captureEnabled = samplingConfig.capture_enabled; @@ -179,6 +187,10 @@ export class SessionReplayJoinedConfigGenerator { ); } + if (targetingConfig && Object.keys(targetingConfig).length > 0) { + config.targetingConfig = targetingConfig; + } + this.localConfig.loggerProvider.debug( JSON.stringify({ name: 'session replay joined config', config: getDebugConfig(config) }, null, 2), ); diff --git a/packages/session-replay-browser/src/config/types.ts b/packages/session-replay-browser/src/config/types.ts index f93f323b3..b553522da 100644 --- a/packages/session-replay-browser/src/config/types.ts +++ b/packages/session-replay-browser/src/config/types.ts @@ -1,5 +1,6 @@ import { IConfig, LogLevel, ILogger } from '@amplitude/analytics-core'; import { StoreType, ConsoleLogLevel } from '../typings/session-replay'; +import { TargetingFlag } from '@amplitude/targeting'; export interface SamplingConfig { sample_rate: number; @@ -26,11 +27,14 @@ export interface LoggingConfig { }; } +export type TargetingConfig = TargetingFlag; + export type SessionReplayRemoteConfig = { sr_sampling_config?: SamplingConfig; sr_privacy_config?: PrivacyConfig; sr_interaction_config?: InteractionConfig; sr_logging_config?: LoggingConfig; + sr_targeting_config?: TargetingConfig; }; export interface SessionReplayRemoteConfigAPIResponse { @@ -141,6 +145,7 @@ export interface SessionReplayLocalConfig extends IConfig { */ useWebWorker: boolean; }; + userProperties?: { [key: string]: any }; /** * Remove certain parts of the DOM from being captured. These are typically ignored when blocking by selectors. @@ -168,6 +173,7 @@ export interface SessionReplayJoinedConfig extends SessionReplayLocalConfig { captureEnabled?: boolean; interactionConfig?: InteractionConfig; loggingConfig?: LoggingConfig; + targetingConfig?: TargetingConfig; } export interface SessionReplayRemoteConfigFetch { diff --git a/packages/session-replay-browser/src/index.ts b/packages/session-replay-browser/src/index.ts index b4a48de36..faf208462 100644 --- a/packages/session-replay-browser/src/index.ts +++ b/packages/session-replay-browser/src/index.ts @@ -1,5 +1,13 @@ import sessionReplay from './session-replay-factory'; -export const { init, setSessionId, getSessionId, getSessionReplayProperties, flush, shutdown } = sessionReplay; +export const { + init, + setSessionId, + getSessionId, + getSessionReplayProperties, + flush, + shutdown, + evaluateTargetingAndCapture, +} = sessionReplay; export { SessionReplayOptions, StoreType } from './typings/session-replay'; export { SafeLoggerProvider } from './logger'; export { AmplitudeSessionReplay } from './typings/session-replay'; diff --git a/packages/session-replay-browser/src/session-replay-factory.ts b/packages/session-replay-browser/src/session-replay-factory.ts index b0ffbf37e..27ec1e55b 100644 --- a/packages/session-replay-browser/src/session-replay-factory.ts +++ b/packages/session-replay-browser/src/session-replay-factory.ts @@ -16,6 +16,11 @@ const createInstance: () => AmplitudeSessionReplay = () => { const sessionReplay = new SessionReplay(); return { init: debugWrapper(sessionReplay.init.bind(sessionReplay), 'init', getLogConfig(sessionReplay)), + evaluateTargetingAndCapture: debugWrapper( + sessionReplay.evaluateTargetingAndCapture.bind(sessionReplay), + 'evaluateTargetingAndRecord', + getLogConfig(sessionReplay), + ), setSessionId: debugWrapper( sessionReplay.setSessionId.bind(sessionReplay), 'setSessionId', diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 0edaeaac7..8b9d4560f 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -5,10 +5,12 @@ import { getGlobalScope, ILogger, LogLevel, + SpecialEventType, } from '@amplitude/analytics-core'; // Import only specific types to avoid pulling in the entire rrweb-types package import { EventType as RRWebEventType, scrollCallback, eventWithTime } from '@amplitude/rrweb-types'; +import { TargetingParameters } from '@amplitude/targeting'; import { createSessionReplayJoinedConfigGenerator } from './config/joined-config'; import { LoggingConfig, @@ -33,6 +35,7 @@ import { generateHashCode, getDebugConfig, getPageUrl, getStorageSize, isSession import { clickBatcher, clickHook, clickNonBatcher } from './hooks/click'; import { ScrollWatcher } from './hooks/scroll'; import { SessionIdentifiers } from './identifiers'; +import { evaluateTargetingAndStore } from './targeting/targeting-manager'; import { AmplitudeSessionReplay, SessionReplayEventsManager as AmplitudeSessionReplayEventsManager, @@ -62,6 +65,7 @@ export class SessionReplay implements AmplitudeSessionReplay { recordCancelCallback: ReturnType | null = null; eventCount = 0; eventCompressor: EventCompressor | undefined; + sessionTargetingMatch = false; // Visible for testing only pageLeaveFns: PageLeaveFn[] = []; @@ -186,14 +190,20 @@ export class SessionReplay implements AmplitudeSessionReplay { this.teardownEventListeners(false); - void this.initialize(true); + await this.evaluateTargetingAndCapture({ userProperties: options.userProperties }, true); } setSessionId(sessionId: string | number, deviceId?: string) { return returnWrapper(this.asyncSetSessionId(sessionId, deviceId)); } - async asyncSetSessionId(sessionId: string | number, deviceId?: string) { + async asyncSetSessionId( + sessionId: string | number, + deviceId?: string, + options?: { userProperties?: { [key: string]: any } }, + ) { + this.sessionTargetingMatch = false; + const previousSessionId = this.identifiers && this.identifiers.sessionId; if (previousSessionId) { this.sendEvents(previousSessionId); @@ -211,7 +221,7 @@ export class SessionReplay implements AmplitudeSessionReplay { const { joinedConfig } = await this.joinedConfigGenerator.generateJoinedConfig(this.identifiers.sessionId); this.config = joinedConfig; } - void this.recordEvents(); + await this.evaluateTargetingAndCapture({ userProperties: options?.userProperties }); } getSessionReplayProperties() { @@ -273,6 +283,47 @@ export class SessionReplay implements AmplitudeSessionReplay { }); }; + evaluateTargetingAndCapture = async ( + targetingParams: Pick, + isInit = false, + ) => { + if (!this.identifiers || !this.identifiers.sessionId || !this.config) { + if (this.identifiers && !this.identifiers.sessionId) { + this.loggerProvider.log('Session ID has not been set yet, cannot evaluate targeting for Session Replay.'); + } else { + this.loggerProvider.warn('Session replay init has not been called, cannot evaluate targeting.'); + } + return; + } + + if (this.config.targetingConfig && !this.sessionTargetingMatch) { + let eventForTargeting = targetingParams.event; + if ( + eventForTargeting && + Object.values(SpecialEventType).includes(eventForTargeting.event_type as SpecialEventType) + ) { + eventForTargeting = undefined; + } + + // We're setting this on this class because fetching the value from idb + // is async, we need to access this value synchronously (for record + // and for getSessionReplayProperties - both synchronous fns) + this.sessionTargetingMatch = await evaluateTargetingAndStore({ + sessionId: Number(this.identifiers.sessionId), + targetingConfig: this.config.targetingConfig, + loggerProvider: this.loggerProvider, + apiKey: this.config.apiKey, + targetingParams: { userProperties: targetingParams.userProperties, event: eventForTargeting }, + }); + } + + if (isInit) { + void this.initialize(true); + } else { + await this.recordEvents(); + } + }; + sendEvents(sessionId?: string | number) { const sessionIdToSend = sessionId || this.identifiers?.sessionId; const deviceId = this.getDeviceId(); @@ -325,11 +376,27 @@ export class SessionReplay implements AmplitudeSessionReplay { return false; } - const isInSample = isSessionInSample(this.identifiers.sessionId, this.config.sampleRate); - if (!isInSample) { - this.loggerProvider.log(`Opting session ${this.identifiers.sessionId} out of recording due to sample rate.`); + // If targetingConfig exists, we'll use the sessionTargetingMatch to determine whether to record + // Otherwise, we'll evaluate the session against the overall sample rate + if (this.config.targetingConfig) { + if (!this.sessionTargetingMatch) { + this.loggerProvider.log( + `Not capturing replays for session ${this.identifiers.sessionId} due to not matching targeting conditions.`, + ); + return false; + } + this.loggerProvider.log( + `Capturing replays for session ${this.identifiers.sessionId} due to matching targeting conditions.`, + ); + } else { + const isInSample = isSessionInSample(this.identifiers.sessionId, this.config.sampleRate); + if (!isInSample) { + this.loggerProvider.log(`Opting session ${this.identifiers.sessionId} out of recording due to sample rate.`); + } + return isInSample; } - return isInSample; + + return true; } getBlockSelectors(): string | string[] | undefined { diff --git a/packages/session-replay-browser/src/targeting/targeting-idb-store.ts b/packages/session-replay-browser/src/targeting/targeting-idb-store.ts new file mode 100644 index 000000000..920222462 --- /dev/null +++ b/packages/session-replay-browser/src/targeting/targeting-idb-store.ts @@ -0,0 +1,111 @@ +import { Logger as ILogger } from '@amplitude/analytics-types'; +import { DBSchema, IDBPDatabase, openDB } from 'idb'; + +export const MAX_IDB_STORAGE_LENGTH = 1000 * 60 * 60 * 24 * 2; // 2 days +export interface SessionReplayTargetingDB extends DBSchema { + sessionTargetingMatch: { + key: number; + value: { + sessionId: number; + targetingMatch: boolean; + }; + }; +} + +export class TargetingIDBStore { + dbs: { [apiKey: string]: IDBPDatabase } = {}; + + createStore = async (dbName: string) => { + return await openDB(dbName, 1, { + upgrade: (db: IDBPDatabase) => { + if (!db.objectStoreNames.contains('sessionTargetingMatch')) { + db.createObjectStore('sessionTargetingMatch', { + keyPath: 'sessionId', + }); + } + }, + }); + }; + + openOrCreateDB = async (apiKey: string) => { + if (this.dbs && this.dbs[apiKey]) { + return this.dbs[apiKey]; + } + const dbName = `${apiKey.substring(0, 10)}_amp_session_replay_targeting`; + const db = await this.createStore(dbName); + this.dbs[apiKey] = db; + return db; + }; + + getTargetingMatchForSession = async ({ + loggerProvider, + apiKey, + sessionId, + }: { + loggerProvider: ILogger; + apiKey: string; + sessionId: number; + }) => { + try { + const db = await this.openOrCreateDB(apiKey); + const targetingMatchForSession = await db.get<'sessionTargetingMatch'>('sessionTargetingMatch', sessionId); + + return targetingMatchForSession?.targetingMatch; + } catch (e) { + loggerProvider.warn(`Failed to get targeting match for session id ${sessionId}: ${e as string}`); + } + return undefined; + }; + + storeTargetingMatchForSession = async ({ + loggerProvider, + apiKey, + sessionId, + targetingMatch, + }: { + loggerProvider: ILogger; + apiKey: string; + sessionId: number; + targetingMatch: boolean; + }) => { + try { + const db = await this.openOrCreateDB(apiKey); + const targetingMatchForSession = await db.put<'sessionTargetingMatch'>('sessionTargetingMatch', { + targetingMatch, + sessionId, + }); + + return targetingMatchForSession; + } catch (e) { + loggerProvider.warn(`Failed to store targeting match for session id ${sessionId}: ${e as string}`); + } + return undefined; + }; + + clearStoreOfOldSessions = async ({ + loggerProvider, + apiKey, + currentSessionId, + }: { + loggerProvider: ILogger; + apiKey: string; + currentSessionId: number; + }) => { + try { + const db = await this.openOrCreateDB(apiKey); + const tx = db.transaction<'sessionTargetingMatch', 'readwrite'>('sessionTargetingMatch', 'readwrite'); + const allTargetingMatchObjs = await tx.store.getAll(); + for (let i = 0; i < allTargetingMatchObjs.length; i++) { + const targetingMatchObj = allTargetingMatchObjs[i]; + const amountOfTimeSinceSession = Date.now() - targetingMatchObj.sessionId; + if (targetingMatchObj.sessionId !== currentSessionId && amountOfTimeSinceSession > MAX_IDB_STORAGE_LENGTH) { + await tx.store.delete(targetingMatchObj.sessionId); + } + } + await tx.done; + } catch (e) { + loggerProvider.warn(`Failed to clear old targeting matches for sessions: ${e as string}`); + } + }; +} +export const targetingIDBStore = new TargetingIDBStore(); diff --git a/packages/session-replay-browser/src/targeting/targeting-manager.ts b/packages/session-replay-browser/src/targeting/targeting-manager.ts new file mode 100644 index 000000000..9dca89a91 --- /dev/null +++ b/packages/session-replay-browser/src/targeting/targeting-manager.ts @@ -0,0 +1,64 @@ +import type { TargetingParameters } from '@amplitude/targeting'; +import { TargetingConfig } from '../config/types'; +import { Logger } from '@amplitude/analytics-types'; +import { targetingIDBStore } from './targeting-idb-store'; + +export const evaluateTargetingAndStore = async ({ + sessionId, + targetingConfig, + loggerProvider, + apiKey, + targetingParams, +}: { + sessionId: number; + targetingConfig: TargetingConfig; + loggerProvider: Logger; + apiKey: string; + targetingParams?: Pick; +}) => { + await targetingIDBStore.clearStoreOfOldSessions({ + loggerProvider: loggerProvider, + apiKey: apiKey, + currentSessionId: sessionId, + }); + + const idbTargetingMatch = await targetingIDBStore.getTargetingMatchForSession({ + loggerProvider: loggerProvider, + apiKey: apiKey, + sessionId: sessionId, + }); + if (idbTargetingMatch === true) { + return true; + } + + // If the targeting config is undefined or an empty object, + // assume the response was valid but no conditions were set, + // so all users match targeting + let sessionTargetingMatch = true; + try { + // Dynamic import of the targeting package + const { evaluateTargeting: evaluateTargetingPackage } = await import('@amplitude/targeting'); + + const targetingResult = await evaluateTargetingPackage({ + ...targetingParams, + flag: targetingConfig, + sessionId: sessionId, + apiKey: apiKey, + loggerProvider: loggerProvider, + }); + if (targetingResult && targetingResult.sr_targeting_config) { + sessionTargetingMatch = targetingResult.sr_targeting_config.key === 'on'; + } + + void targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: loggerProvider, + apiKey: apiKey, + sessionId: sessionId, + targetingMatch: sessionTargetingMatch, + }); + } catch (err: unknown) { + const knownError = err as Error; + loggerProvider.warn(knownError.message); + } + return sessionTargetingMatch; +}; diff --git a/packages/session-replay-browser/src/typings/session-replay.ts b/packages/session-replay-browser/src/typings/session-replay.ts index bbe776f12..ef90991ca 100644 --- a/packages/session-replay-browser/src/typings/session-replay.ts +++ b/packages/session-replay-browser/src/typings/session-replay.ts @@ -1,5 +1,6 @@ import { AmplitudeReturn, ServerZone } from '@amplitude/analytics-core'; import { SessionReplayJoinedConfig, SessionReplayLocalConfig, SessionReplayVersion } from '../config/types'; +import { TargetingParameters } from '@amplitude/targeting'; export type StorageData = { totalStorageSize: number; @@ -93,6 +94,10 @@ export interface AmplitudeSessionReplay { setSessionId: (sessionId: string | number, deviceId?: string) => AmplitudeReturn; getSessionId: () => string | number | undefined; getSessionReplayProperties: () => { [key: string]: boolean | string | null }; + evaluateTargetingAndCapture: ( + targetingParams: Pick, + isInit?: boolean, + ) => Promise; flush: (useRetry: boolean) => Promise; shutdown: () => void; } diff --git a/packages/session-replay-browser/test/flag-config-data.ts b/packages/session-replay-browser/test/flag-config-data.ts new file mode 100644 index 000000000..f783335a6 --- /dev/null +++ b/packages/session-replay-browser/test/flag-config-data.ts @@ -0,0 +1,84 @@ +export const flagConfig = { + key: 'sr_targeting_config', + variants: { + on: { key: 'on' }, + off: { key: 'off' }, + }, + segments: [ + { + metadata: { segmentName: 'sign in trigger' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'xdfrewd', + allocations: [ + { + range: [0, 99], + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event', 'event_type'], + op: 'is', + values: ['Sign In'], + }, + ], + ], + }, + { + metadata: { segmentName: 'user property' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'Rpr5h4vy', + allocations: [ + { + range: [0, 99], + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'user', 'user_properties', 'country'], + op: 'set contains any', + values: ['united states'], + }, + ], + ], + }, + { + metadata: { segmentName: 'leftover allocation' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'T5lhyRo', + allocations: [ + { + range: [0, 9], // Selects 10% of users that match these conditions + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + }, + { + variant: 'off', + }, + ], +}; diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 2e430470b..d96051ff9 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import * as AnalyticsCore from '@amplitude/analytics-core'; -import { LogLevel, ILogger, ServerZone } from '@amplitude/analytics-core'; +import { LogLevel, ILogger, ServerZone, SpecialEventType } from '@amplitude/analytics-core'; import { SessionReplayLocalConfig } from '../src/config/local-config'; import { NetworkObservers, NetworkRequestEvent } from '../src/observers'; @@ -604,7 +604,10 @@ describe('SessionReplay', () => { test('should update the session id and start recording', async () => { await sessionReplay.init(apiKey, mockOptions).promise; - mockRecordFunction.mockReset(); + + // Clear any calls from initialization + mockRecordFunction.mockClear(); + expect(sessionReplay.identifiers?.sessionId).toEqual(123); expect(sessionReplay.identifiers?.sessionReplayId).toEqual('1a2b3c/123'); if (!sessionReplay.eventsManager || !sessionReplay.joinedConfigGenerator || !sessionReplay.config) { @@ -625,7 +628,9 @@ describe('SessionReplay', () => { expect(sessionReplay.identifiers?.sessionId).toEqual(456); expect(sessionReplay.identifiers?.sessionReplayId).toEqual('1a2b3c/456'); await generateJoinedConfigPromise; - expect(mockRecordFunction).toHaveBeenCalledTimes(1); + // With targeting functionality, setSessionId triggers recording via evaluateTargetingAndCapture + // The function may be called multiple times due to focus listeners or other async operations + expect(mockRecordFunction).toHaveBeenCalled(); expect(sessionReplay.config).toEqual(updatedConfig); }); @@ -668,6 +673,27 @@ describe('SessionReplay', () => { expect(sessionReplay.identifiers?.deviceId).toEqual('9l8m7n'); expect(sessionReplay.getDeviceId()).toEqual('9l8m7n'); }); + + test('should call asyncSetSessionId with userProperties when options provided', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + const evaluateTargetingAndCaptureSpy = jest.spyOn(sessionReplay, 'evaluateTargetingAndCapture'); + + // Test with userProperties + const userProperties = { age: 30, city: 'New York' }; + await (sessionReplay as any).asyncSetSessionId(456, '9l8m7n', { userProperties }); + + expect(evaluateTargetingAndCaptureSpy).toHaveBeenCalledWith({ userProperties }); + + // Test without userProperties (options is undefined) + await (sessionReplay as any).asyncSetSessionId(789, '9l8m7n'); + + expect(evaluateTargetingAndCaptureSpy).toHaveBeenCalledWith({ userProperties: undefined }); + + // Test with empty options + await (sessionReplay as any).asyncSetSessionId(101, '9l8m7n', {}); + + expect(evaluateTargetingAndCaptureSpy).toHaveBeenCalledWith({ userProperties: undefined }); + }); }); describe('getSessionId', () => { @@ -2123,4 +2149,204 @@ describe('SessionReplay', () => { getRecordFunctionSpy.mockRestore(); }); }); + + describe('evaluateTargetingAndCapture', () => { + test('should return early if no identifiers', async () => { + const sessionReplay = new SessionReplay(); + sessionReplay.identifiers = undefined; + sessionReplay.loggerProvider = mockLoggerProvider; + + await sessionReplay.evaluateTargetingAndCapture({}); + + expect(mockLoggerProvider.warn).toHaveBeenCalledWith( + 'Session replay init has not been called, cannot evaluate targeting.', + ); + }); + + test('should return early if no sessionId', async () => { + const sessionReplay = new SessionReplay(); + sessionReplay.identifiers = { sessionId: undefined, deviceId: '123', sessionReplayId: '123/undefined' }; + sessionReplay.loggerProvider = mockLoggerProvider; + + await sessionReplay.evaluateTargetingAndCapture({}); + + expect(mockLoggerProvider.log).toHaveBeenCalledWith( + 'Session ID has not been set yet, cannot evaluate targeting for Session Replay.', + ); + }); + + test('should return early if no config', async () => { + const sessionReplay = new SessionReplay(); + sessionReplay.identifiers = { sessionId: 123, deviceId: '123', sessionReplayId: '123/123' }; + sessionReplay.config = undefined; + sessionReplay.loggerProvider = mockLoggerProvider; + + await sessionReplay.evaluateTargetingAndCapture({}); + + expect(mockLoggerProvider.warn).toHaveBeenCalledWith( + 'Session replay init has not been called, cannot evaluate targeting.', + ); + }); + + test('should call initialize when isInit is true', async () => { + const sessionReplay = new SessionReplay(); + await sessionReplay.init(apiKey, mockOptions).promise; + + const initializeSpy = jest.spyOn(sessionReplay, 'initialize'); + + await sessionReplay.evaluateTargetingAndCapture({}, true); + + expect(initializeSpy).toHaveBeenCalledWith(true); + }); + + test('should call recordEvents when isInit is false', async () => { + const sessionReplay = new SessionReplay(); + await sessionReplay.init(apiKey, mockOptions).promise; + + const recordEventsSpy = jest.spyOn(sessionReplay, 'recordEvents'); + + await sessionReplay.evaluateTargetingAndCapture({}, false); + + expect(recordEventsSpy).toHaveBeenCalled(); + }); + + test('should skip targeting evaluation when no targetingConfig', async () => { + const sessionReplay = new SessionReplay(); + await sessionReplay.init(apiKey, mockOptions).promise; + + const recordEventsSpy = jest.spyOn(sessionReplay, 'recordEvents'); + + await sessionReplay.evaluateTargetingAndCapture({}); + + expect(recordEventsSpy).toHaveBeenCalled(); + }); + + test('should skip targeting evaluation when sessionTargetingMatch is already true', async () => { + const sessionReplay = new SessionReplay(); + await sessionReplay.init(apiKey, mockOptions).promise; + + // Set targeting config directly on the config object + if (sessionReplay.config) { + sessionReplay.config.targetingConfig = { + key: 'sr_targeting_config', + variants: { on: { key: 'on' }, off: { key: 'off' } }, + segments: [], + }; + } + + sessionReplay.sessionTargetingMatch = true; + const recordEventsSpy = jest.spyOn(sessionReplay, 'recordEvents'); + + await sessionReplay.evaluateTargetingAndCapture({}); + + expect(recordEventsSpy).toHaveBeenCalled(); + }); + + test('should execute targeting evaluation branch when targetingConfig exists and sessionTargetingMatch is false', async () => { + const sessionReplay = new SessionReplay(); + + // Set up namespace config to include targeting config + __setNamespaceConfig({ + sr_sampling_config: samplingConfig, + sr_privacy_config: {}, + sr_targeting_config: { + key: 'sr_targeting_config', + variants: { on: { key: 'on' }, off: { key: 'off' } }, + segments: [], + }, + }); + + await sessionReplay.init(apiKey, mockOptions).promise; + + // Ensure sessionTargetingMatch is false to trigger the evaluation + sessionReplay.sessionTargetingMatch = false; + + const recordEventsSpy = jest.spyOn(sessionReplay, 'recordEvents'); + const userProperties = { age: 30, city: 'San Francisco' }; + + await sessionReplay.evaluateTargetingAndCapture({ userProperties }); + + // Verify the targeting evaluation branch was executed by checking that sessionTargetingMatch was updated + expect(typeof sessionReplay.sessionTargetingMatch).toBe('boolean'); + expect(recordEventsSpy).toHaveBeenCalled(); + }); + + test('should handle special event types in targeting evaluation', async () => { + const sessionReplay = new SessionReplay(); + + // Set up namespace config to include targeting config + __setNamespaceConfig({ + sr_sampling_config: samplingConfig, + sr_privacy_config: {}, + sr_targeting_config: { + key: 'sr_targeting_config', + variants: { on: { key: 'on' }, off: { key: 'off' } }, + segments: [], + }, + }); + + await sessionReplay.init(apiKey, mockOptions).promise; + + // Ensure sessionTargetingMatch is false to trigger the evaluation + sessionReplay.sessionTargetingMatch = false; + + const specialEvent = { + event_type: SpecialEventType.IDENTIFY, + event_properties: {}, + }; + + await sessionReplay.evaluateTargetingAndCapture({ event: specialEvent }); + + // Verify the function executed without throwing errors + expect(typeof sessionReplay.sessionTargetingMatch).toBe('boolean'); + }); + }); + + describe('getShouldRecord - targeting scenarios', () => { + test('should return false when targetingConfig exists but sessionTargetingMatch is false', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + + // Set targeting config directly on the config object + if (sessionReplay.config) { + sessionReplay.config.targetingConfig = { + key: 'sr_targeting_config', + variants: { on: { key: 'on' }, off: { key: 'off' } }, + segments: [], + }; + } + + sessionReplay.sessionTargetingMatch = false; + const shouldRecord = sessionReplay.getShouldRecord(); + + expect(shouldRecord).toBe(false); + expect(mockLoggerProvider.log).toHaveBeenCalledWith( + `Not capturing replays for session ${ + mockOptions.sessionId?.toString() || '' + } due to not matching targeting conditions.`, + ); + }); + + test('should return true when targetingConfig exists and sessionTargetingMatch is true', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + + // Set targeting config directly on the config object + if (sessionReplay.config) { + sessionReplay.config.targetingConfig = { + key: 'sr_targeting_config', + variants: { on: { key: 'on' }, off: { key: 'off' } }, + segments: [], + }; + } + + sessionReplay.sessionTargetingMatch = true; + const shouldRecord = sessionReplay.getShouldRecord(); + + expect(shouldRecord).toBe(true); + expect(mockLoggerProvider.log).toHaveBeenCalledWith( + `Capturing replays for session ${ + mockOptions.sessionId?.toString() || '' + } due to matching targeting conditions.`, + ); + }); + }); }); diff --git a/packages/session-replay-browser/test/targeting/targeting-idb-store.test.ts b/packages/session-replay-browser/test/targeting/targeting-idb-store.test.ts new file mode 100644 index 000000000..1c8084ae4 --- /dev/null +++ b/packages/session-replay-browser/test/targeting/targeting-idb-store.test.ts @@ -0,0 +1,186 @@ +import { Logger } from '@amplitude/analytics-types'; +import { IDBPDatabase } from 'idb'; +import { SessionReplayTargetingDB, targetingIDBStore } from '../../src/targeting/targeting-idb-store'; + +type MockedLogger = jest.Mocked; + +const apiKey = 'static_key'; + +describe('TargetingIDBStore', () => { + const mockLoggerProvider: MockedLogger = { + error: jest.fn(), + log: jest.fn(), + disable: jest.fn(), + enable: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + let db: IDBPDatabase; + beforeEach(async () => { + db = await targetingIDBStore.openOrCreateDB('static_key'); + await db.clear('sessionTargetingMatch'); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + + describe('getTargetingMatchForSession', () => { + test('should return the targeting match from idb store', async () => { + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + targetingMatch: true, + }); + const targetingMatch = await targetingIDBStore.getTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + }); + expect(targetingMatch).toEqual(true); + }); + test('should return undefined if no matching entry in the store', async () => { + const targetingMatch = await targetingIDBStore.getTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + }); + expect(targetingMatch).toEqual(undefined); + }); + test('should catch errors', async () => { + const mockDB: IDBPDatabase = { + get: jest.fn().mockImplementation(() => Promise.reject('error')), + } as unknown as IDBPDatabase; + jest.spyOn(targetingIDBStore, 'openOrCreateDB').mockResolvedValueOnce(mockDB); + await targetingIDBStore.getTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( + 'Failed to get targeting match for session id 123: error', + ); + }); + }); + + describe('storeTargetingMatchForSession', () => { + test('should add the targeting match to idb store', async () => { + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + targetingMatch: true, + }); + const targetingMatch = await targetingIDBStore.getTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + }); + expect(targetingMatch).toEqual(true); + }); + test('should catch errors', async () => { + const mockDB: IDBPDatabase = { + put: jest.fn().mockImplementation(() => Promise.reject('error')), + } as unknown as IDBPDatabase; + jest.spyOn(targetingIDBStore, 'openOrCreateDB').mockResolvedValueOnce(mockDB); + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + sessionId: 123, + apiKey, + targetingMatch: true, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( + 'Failed to store targeting match for session id 123: error', + ); + }); + }); + + describe('clearStoreOfOldSessions', () => { + test('should delete object stores with sessions older than 2 days', async () => { + // Set current time to 08:30 + jest.useFakeTimers().setSystemTime(new Date('2023-07-31 08:30:00').getTime()); + // Current session from one hour before, 07:30 + const currentSessionId = new Date('2023-07-31 07:30:00').getTime(); + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + apiKey, + sessionId: currentSessionId, + targetingMatch: true, + }); + // Add session from the same day + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + apiKey, + sessionId: new Date('2023-07-31 05:30:00').getTime(), + targetingMatch: true, + }); + // Add session from one month ago + await targetingIDBStore.storeTargetingMatchForSession({ + loggerProvider: mockLoggerProvider, + apiKey, + sessionId: new Date('2023-06-31 10:30:00').getTime(), + targetingMatch: true, + }); + const allEntries = + targetingIDBStore.dbs && (await targetingIDBStore.dbs['static_key'].getAll('sessionTargetingMatch')); + expect(allEntries).toEqual([ + { + sessionId: new Date('2023-06-31 10:30:00').getTime(), + targetingMatch: true, + }, + { + sessionId: new Date('2023-07-31 05:30:00').getTime(), + targetingMatch: true, + }, + { + sessionId: currentSessionId, + targetingMatch: true, + }, + ]); + + await targetingIDBStore.clearStoreOfOldSessions({ + loggerProvider: mockLoggerProvider, + apiKey, + currentSessionId, + }); + + const allEntriesUpdated = + targetingIDBStore.dbs && (await targetingIDBStore.dbs['static_key'].getAll('sessionTargetingMatch')); + // Only one month old entry should be deleted + expect(allEntriesUpdated).toEqual([ + { + sessionId: new Date('2023-07-31 05:30:00').getTime(), + targetingMatch: true, + }, + { + sessionId: currentSessionId, + targetingMatch: true, + }, + ]); + }); + test('should catch errors', async () => { + const mockDB: IDBPDatabase = { + transaction: jest.fn().mockImplementation(() => { + throw new Error('error'); + }), + } as unknown as IDBPDatabase; + jest.spyOn(targetingIDBStore, 'openOrCreateDB').mockResolvedValueOnce(mockDB); + await targetingIDBStore.clearStoreOfOldSessions({ + loggerProvider: mockLoggerProvider, + currentSessionId: 123, + apiKey, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual( + 'Failed to clear old targeting matches for sessions: Error: error', + ); + }); + }); +}); diff --git a/packages/session-replay-browser/test/targeting/targeting-manager.test.ts b/packages/session-replay-browser/test/targeting/targeting-manager.test.ts new file mode 100644 index 000000000..826b407f4 --- /dev/null +++ b/packages/session-replay-browser/test/targeting/targeting-manager.test.ts @@ -0,0 +1,138 @@ +import { Logger } from '@amplitude/analytics-types'; +import * as Targeting from '@amplitude/targeting'; +import { IDBPDatabase } from 'idb'; +import { SessionReplayJoinedConfig } from '../../src/config/types'; +import { SessionReplayTargetingDB, targetingIDBStore } from '../../src/targeting/targeting-idb-store'; +import { evaluateTargetingAndStore } from '../../src/targeting/targeting-manager'; +import { flagConfig } from '../flag-config-data'; + +type MockedLogger = jest.Mocked; + +jest.mock('@amplitude/targeting'); +type MockedTargeting = jest.Mocked; + +describe('Targeting Manager', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { evaluateTargeting } = Targeting as MockedTargeting; + let originalFetch: typeof global.fetch; + const mockLoggerProvider: MockedLogger = { + error: jest.fn(), + log: jest.fn(), + disable: jest.fn(), + enable: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + const config: SessionReplayJoinedConfig = { + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + sampleRate: 1, + targetingConfig: flagConfig, + } as unknown as SessionReplayJoinedConfig; + let db: IDBPDatabase; + beforeEach(async () => { + db = await targetingIDBStore.openOrCreateDB('static_key'); + await db.clear('sessionTargetingMatch'); + jest.useFakeTimers(); + originalFetch = global.fetch; + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + }), + ) as jest.Mock; + }); + afterEach(() => { + jest.resetAllMocks(); + global.fetch = originalFetch; + + jest.useRealTimers(); + }); + + describe('evaluateTargetingAndStore', () => { + let storeTargetingMatchForSessionMock: jest.SpyInstance; + let getTargetingMatchForSessionMock: jest.SpyInstance; + beforeEach(() => { + storeTargetingMatchForSessionMock = jest.spyOn(targetingIDBStore, 'storeTargetingMatchForSession'); + getTargetingMatchForSessionMock = jest.spyOn(targetingIDBStore, 'getTargetingMatchForSession'); + }); + test('should return a true match from IndexedDB', async () => { + jest.spyOn(targetingIDBStore, 'getTargetingMatchForSession').mockResolvedValueOnce(true); + const sessionTargetingMatch = await evaluateTargetingAndStore({ + sessionId: 123, + loggerProvider: config.loggerProvider, + apiKey: config.apiKey, + targetingConfig: flagConfig, + }); + expect(getTargetingMatchForSessionMock).toHaveBeenCalled(); + expect(evaluateTargeting).not.toHaveBeenCalled(); + expect(sessionTargetingMatch).toBe(true); + }); + + test('should use remote config to determine targeting match', async () => { + jest.spyOn(targetingIDBStore, 'getTargetingMatchForSession').mockResolvedValueOnce(false); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + (evaluateTargeting as jest.Mock).mockResolvedValueOnce({ + sr_targeting_config: { + key: 'on', + }, + }); + const mockUserProperties = { + country: 'US', + city: 'San Francisco', + }; + const sessionTargetingMatch = await evaluateTargetingAndStore({ + sessionId: 123, + loggerProvider: config.loggerProvider, + apiKey: config.apiKey, + targetingConfig: flagConfig, + targetingParams: { + userProperties: mockUserProperties, + }, + }); + expect(evaluateTargeting).toHaveBeenCalledWith({ + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + flag: flagConfig, + sessionId: 123, + userProperties: mockUserProperties, + }); + expect(sessionTargetingMatch).toBe(true); + }); + test('should store sessionTargetingMatch', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + (evaluateTargeting as jest.Mock).mockResolvedValueOnce({ + sr_targeting_config: { + key: 'on', + }, + }); + const sessionTargetingMatch = await evaluateTargetingAndStore({ + sessionId: 123, + loggerProvider: config.loggerProvider, + apiKey: config.apiKey, + targetingConfig: flagConfig, + }); + expect(storeTargetingMatchForSessionMock).toHaveBeenCalledWith({ + targetingMatch: true, + sessionId: 123, + apiKey: config.apiKey, + loggerProvider: mockLoggerProvider, + }); + expect(sessionTargetingMatch).toBe(true); + }); + test('should handle error', async () => { + jest.spyOn(targetingIDBStore, 'storeTargetingMatchForSession').mockImplementationOnce(() => { + throw new Error('storage error'); + }); + const sessionTargetingMatch = await evaluateTargetingAndStore({ + sessionId: 123, + loggerProvider: config.loggerProvider, + apiKey: config.apiKey, + targetingConfig: flagConfig, + }); + expect(sessionTargetingMatch).toBe(true); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockLoggerProvider.warn).toHaveBeenCalledTimes(1); + expect(mockLoggerProvider.warn.mock.calls[0][0]).toEqual('storage error'); + }); + }); +}); diff --git a/packages/targeting/CHANGELOG.md b/packages/targeting/CHANGELOG.md new file mode 100644 index 000000000..ab026e701 --- /dev/null +++ b/packages/targeting/CHANGELOG.md @@ -0,0 +1,36 @@ +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [0.2.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/targeting@0.1.1...@amplitude/targeting@0.2.0) (2024-07-01) + +### Features + +- **targeting:** add support for multiple events in targeting evaluation + ([fbe083e](https://github.com/amplitude/Amplitude-TypeScript/commit/fbe083e3782f07805b7f146778de663899b1afbd)) + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [0.1.1](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/targeting@0.1.0...@amplitude/targeting@0.1.1) (2024-05-28) + +**Note:** Version bump only for package @amplitude/targeting + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 0.1.0 (2024-04-08) + +### Features + +- **targeting:** allow for bucketing on session id + ([288e95f](https://github.com/amplitude/Amplitude-TypeScript/commit/288e95f2fd24ed654567d40cf75e847fa5973351)) +- **targeting:** introduce new package for targeting + ([0da1316](https://github.com/amplitude/Amplitude-TypeScript/commit/0da131638fbd7b92386eb2897a8b689b09a7a22f)) + +# Change Log diff --git a/packages/targeting/README.md b/packages/targeting/README.md new file mode 100644 index 000000000..7c49b90d4 --- /dev/null +++ b/packages/targeting/README.md @@ -0,0 +1,10 @@ +

+ + + +
+

+ +# @amplitude/targeting + +Internal Node package for Amplitude \ No newline at end of file diff --git a/packages/targeting/jest.config.js b/packages/targeting/jest.config.js new file mode 100644 index 000000000..9f07084a5 --- /dev/null +++ b/packages/targeting/jest.config.js @@ -0,0 +1,11 @@ +const baseConfig = require('../../jest.config.js'); +const package = require('./package'); + +module.exports = { + ...baseConfig, + displayName: package.name, + rootDir: '.', + testEnvironment: 'jsdom', + coveragePathIgnorePatterns: ['index.ts'], + setupFilesAfterEnv: ['./test/jest-setup.js'], +}; diff --git a/packages/targeting/package.json b/packages/targeting/package.json new file mode 100644 index 000000000..85bdb0691 --- /dev/null +++ b/packages/targeting/package.json @@ -0,0 +1,62 @@ +{ + "name": "@amplitude/targeting", + "version": "0.2.0", + "description": "", + "author": "Amplitude Inc", + "homepage": "https://github.com/amplitude/Amplitude-TypeScript", + "license": "MIT", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "types": "lib/esm/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/amplitude/Amplitude-TypeScript.git" + }, + "scripts": { + "build": "yarn bundle && yarn build:es5 && yarn build:esm", + "bundle": "rollup --config rollup.config.js", + "build:es5": "tsc -p ./tsconfig.es5.json", + "build:esm": "tsc -p ./tsconfig.esm.json", + "clean": "rimraf node_modules lib coverage", + "fix": "yarn fix:eslint & yarn fix:prettier", + "fix:eslint": "eslint '{src,test}/**/*.ts' --fix", + "fix:prettier": "prettier --write \"{src,test}/**/*.ts\"", + "lint": "yarn lint:eslint & yarn lint:prettier", + "lint:eslint": "eslint '{src,test}/**/*.ts'", + "lint:prettier": "prettier --check \"{src,test}/**/*.ts\"", + "publish": "node ../../scripts/publish/upload-to-s3.js", + "test": "jest", + "typecheck": "tsc -p ./tsconfig.json", + "version": "yarn add @amplitude/analytics-types@\">=1 <3\" @amplitude/analytics-client-common@\">=1 <3\" @amplitude/analytics-core@\">=1 <3\"", + "version-file": "node -p \"'export const VERSION = \\'' + require('./package.json').version + '\\';'\" > src/version.ts" + }, + "bugs": { + "url": "https://github.com/amplitude/Amplitude-TypeScript/issues" + }, + "dependencies": { + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-core": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "@amplitude/experiment-core": "0.7.2", + "idb": "8.0.0", + "tslib": "^2.4.1" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^23.0.4", + "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-typescript": "^10.0.1", + "@types/core-js": "^2.5.8", + "fake-indexeddb": "4.0.2", + "rollup": "^2.79.1", + "rollup-plugin-execute": "^1.1.1", + "rollup-plugin-gzip": "^3.1.0", + "rollup-plugin-terser": "^7.0.2" + }, + "files": [ + "lib" + ] +} diff --git a/packages/targeting/rollup.config.js b/packages/targeting/rollup.config.js new file mode 100644 index 000000000..1ee68c170 --- /dev/null +++ b/packages/targeting/rollup.config.js @@ -0,0 +1,6 @@ +import { iife, umd } from '../../scripts/build/rollup.config'; + +iife.input = umd.input; +iife.output.name = 'targeting'; + +export default [umd, iife]; diff --git a/packages/targeting/src/index.ts b/packages/targeting/src/index.ts new file mode 100644 index 000000000..31709eed9 --- /dev/null +++ b/packages/targeting/src/index.ts @@ -0,0 +1,3 @@ +import targeting from './targeting-factory'; +export const { evaluateTargeting } = targeting; +export { TargetingFlag, TargetingParameters } from './typings/targeting'; diff --git a/packages/targeting/src/targeting-factory.ts b/packages/targeting/src/targeting-factory.ts new file mode 100644 index 000000000..73e57a428 --- /dev/null +++ b/packages/targeting/src/targeting-factory.ts @@ -0,0 +1,11 @@ +import { Targeting } from './targeting'; +import { Targeting as AmplitudeTargeting } from './typings/targeting'; + +const createInstance: () => AmplitudeTargeting = () => { + const targeting = new Targeting(); + return { + evaluateTargeting: targeting.evaluateTargeting.bind(targeting), + }; +}; + +export default createInstance(); diff --git a/packages/targeting/src/targeting-idb-store.ts b/packages/targeting/src/targeting-idb-store.ts new file mode 100644 index 000000000..6ff76f53d --- /dev/null +++ b/packages/targeting/src/targeting-idb-store.ts @@ -0,0 +1,148 @@ +import { Logger as ILogger } from '@amplitude/analytics-types'; +import { DBSchema, IDBPDatabase, IDBPTransaction, openDB } from 'idb'; + +export const MAX_IDB_STORAGE_LENGTH = 1000 * 60 * 60 * 24 * 2; // 2 days + +// This type is constructed to allow for future proofing - in the future we may want +// to track how many of each event is fired, and we may want to track event properties +// Any further fields, like event properties, can be added to this type without causing +// a breaking change +type EventData = { event_type: string }; + +type EventTypeStore = { [event_type: string]: { [timestamp: number]: EventData } }; +export interface TargetingDB extends DBSchema { + eventTypesForSession: { + key: number; + value: { + sessionId: number; + eventTypes: EventTypeStore; + }; + }; +} + +export class TargetingIDBStore { + dbs: { [apiKey: string]: IDBPDatabase } = {}; + + createStore = async (dbName: string) => { + return await openDB(dbName, 1, { + upgrade: (db: IDBPDatabase) => { + if (!db.objectStoreNames.contains('eventTypesForSession')) { + db.createObjectStore('eventTypesForSession', { + keyPath: 'sessionId', + }); + } + }, + }); + }; + + openOrCreateDB = async (apiKey: string) => { + if (this.dbs && this.dbs[apiKey]) { + return this.dbs[apiKey]; + } + const dbName = `${apiKey.substring(0, 10)}_amp_targeting`; + const db = await this.createStore(dbName); + this.dbs[apiKey] = db; + + return db; + }; + + updateEventListForSession = async ({ + sessionId, + eventType, + eventTime, + loggerProvider, + tx, + }: { + sessionId: number; + eventType: string; + eventTime: number; + loggerProvider: ILogger; + tx: IDBPTransaction; + }) => { + try { + const eventTypesForSessionStorage = await tx.store.get(sessionId); + const eventTypesForSession = eventTypesForSessionStorage ? eventTypesForSessionStorage.eventTypes : {}; + const eventTypeStore = eventTypesForSession[eventType] || {}; + + const updatedEventTypes: EventTypeStore = { + ...eventTypesForSession, + [eventType]: { + ...eventTypeStore, + [eventTime]: { event_type: eventType }, + }, + }; + await tx.store.put({ sessionId, eventTypes: updatedEventTypes }); + return updatedEventTypes; + } catch (e) { + loggerProvider.warn(`Failed to store events for targeting ${sessionId}: ${e as string}`); + } + return undefined; + }; + + deleteOldSessionEventTypes = async ({ + currentSessionId, + loggerProvider, + tx, + }: { + currentSessionId: number; + loggerProvider: ILogger; + tx: IDBPTransaction; + }) => { + try { + const allEventTypeObjs = await tx.store.getAll(); + for (let i = 0; i < allEventTypeObjs.length; i++) { + const eventTypeObj = allEventTypeObjs[i]; + const amountOfTimeSinceSession = Date.now() - eventTypeObj.sessionId; + if (eventTypeObj.sessionId !== currentSessionId && amountOfTimeSinceSession > MAX_IDB_STORAGE_LENGTH) { + await tx.store.delete(eventTypeObj.sessionId); + } + } + } catch (e) { + loggerProvider.warn(`Failed to clear old session events for targeting: ${e as string}`); + } + }; + + storeEventTypeForSession = async ({ + loggerProvider, + sessionId, + eventType, + eventTime, + apiKey, + }: { + loggerProvider: ILogger; + apiKey: string; + eventType: string; + eventTime: number; + sessionId: number; + }) => { + try { + const db = await this.openOrCreateDB(apiKey); + + const tx = db.transaction<'eventTypesForSession', 'readwrite'>('eventTypesForSession', 'readwrite'); + if (!tx) { + return; + } + + // Update the list of events for the session + const updatedEventTypes = await this.updateEventListForSession({ + sessionId, + tx, + loggerProvider, + eventType, + eventTime, + }); + + // Clear out sessions older than 2 days + await this.deleteOldSessionEventTypes({ currentSessionId: sessionId, tx, loggerProvider }); + + await tx.done; + + return updatedEventTypes; + } catch (e) { + loggerProvider.warn(`Failed to store events for targeting ${sessionId}: ${e as string}`); + } + return undefined; + }; +} + +export const targetingIDBStore = new TargetingIDBStore(); diff --git a/packages/targeting/src/targeting.ts b/packages/targeting/src/targeting.ts new file mode 100644 index 000000000..ad289c177 --- /dev/null +++ b/packages/targeting/src/targeting.ts @@ -0,0 +1,46 @@ +import { EvaluationEngine } from '@amplitude/experiment-core'; +import { targetingIDBStore } from './targeting-idb-store'; +import { Targeting as AmplitudeTargeting, TargetingParameters } from './typings/targeting'; + +export class Targeting implements AmplitudeTargeting { + evaluationEngine: EvaluationEngine; + + constructor() { + this.evaluationEngine = new EvaluationEngine(); + } + + evaluateTargeting = async ({ + apiKey, + loggerProvider, + event, + sessionId, + userProperties, + deviceId, + flag, + }: TargetingParameters) => { + const eventTypes = + event && event.time + ? await targetingIDBStore.storeEventTypeForSession({ + loggerProvider: loggerProvider, + apiKey: apiKey, + sessionId, + eventType: event.event_type, + eventTime: event.time, + }) + : undefined; + + const eventStrings = eventTypes && new Set(Object.keys(eventTypes)); + + const context = { + session_id: sessionId, + event, + event_types: eventStrings && Array.from(eventStrings), + user: { + device_id: deviceId, + user_properties: userProperties, + }, + }; + const targetingBucket = this.evaluationEngine.evaluate(context, [flag]); + return targetingBucket; + }; +} diff --git a/packages/targeting/src/typings/targeting.ts b/packages/targeting/src/typings/targeting.ts new file mode 100644 index 000000000..170e1bc3c --- /dev/null +++ b/packages/targeting/src/typings/targeting.ts @@ -0,0 +1,17 @@ +import { Event, Logger } from '@amplitude/analytics-types'; +import { EvaluationFlag, EvaluationVariant } from '@amplitude/experiment-core'; + +export type TargetingFlag = EvaluationFlag; +export interface TargetingParameters { + event?: Event; + userProperties?: { [key: string]: any }; + deviceId?: string; + flag: EvaluationFlag; + sessionId: number; + apiKey: string; + loggerProvider: Logger; +} + +export interface Targeting { + evaluateTargeting: (args: TargetingParameters) => Promise>; +} diff --git a/packages/targeting/test/flag-config-data/catch-all.ts b/packages/targeting/test/flag-config-data/catch-all.ts new file mode 100644 index 000000000..02248263c --- /dev/null +++ b/packages/targeting/test/flag-config-data/catch-all.ts @@ -0,0 +1,111 @@ +export const flagCatchAll = { + key: 'sr_targeting_config', + variants: { + on: { key: 'on' }, + off: { key: 'off' }, + }, + segments: [ + { + metadata: { segmentName: 'sign in trigger' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'xdfrewd', // Different salt for each bucket to allow for fallthrough + allocations: [ + { + range: [0, 19], // Selects 20% of users that match these conditions + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event', 'event_type'], + op: 'is', + values: ['Sign In'], + }, + ], + ], + }, + { + metadata: { segmentName: 'multiple event trigger' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'xdfrewd', // Different salt for each bucket to allow for fallthrough + allocations: [ + { + range: [0, 19], // Selects 20% of users that match these conditions + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event_types'], + op: 'set contains', + values: ['Add to Cart', 'Purchase'], + }, + ], + ], + }, + { + metadata: { segmentName: 'user property' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'Rpr5h4vy', // Different salt for each bucket to allow for fallthrough + allocations: [ + { + range: [0, 14], // Selects 15% of users that match these conditions + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'user', 'user_properties', 'country'], + op: 'set contains any', + values: ['united states'], + }, + ], + ], + }, + { + metadata: { segmentName: 'leftover allocation' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'T5lhyRo', // Different salt for each bucket to allow for fallthrough + allocations: [ + { + range: [0, 9], // Selects 10% of users that match these conditions + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + }, + { + variant: 'off', + }, + ], +}; diff --git a/packages/targeting/test/flag-config-data/event-props.ts b/packages/targeting/test/flag-config-data/event-props.ts new file mode 100644 index 000000000..54fb07f4e --- /dev/null +++ b/packages/targeting/test/flag-config-data/event-props.ts @@ -0,0 +1,45 @@ +import { TargetingFlag } from '../../src/typings/targeting'; + +export const flagEventProps: TargetingFlag = { + key: 'sr_targeting_config', + variants: { + off: { + key: 'off', + }, + on: { + key: 'on', + }, + }, + segments: [ + { + metadata: { + segmentName: 'Segment 1', + segmentId: 'uuid1', + }, + bucket: { + selector: ['context', 'session_id'], + salt: 'sphIslYm', + allocations: [ + { + distributions: [ + { + range: [0, 42949673], + variant: 'on', + }, + ], + range: [0, 51], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event', 'event_properties', '[Amplitude] Page URL'], + op: 'is', + values: ['http://localhost:3000/tasks-app'], + }, + ], + ], + }, + ], +}; diff --git a/packages/targeting/test/flag-config-data/multiple-conditions.ts b/packages/targeting/test/flag-config-data/multiple-conditions.ts new file mode 100644 index 000000000..98e1e5296 --- /dev/null +++ b/packages/targeting/test/flag-config-data/multiple-conditions.ts @@ -0,0 +1,46 @@ +export const flagConfigMultipleConditions = { + key: 'sr_targeting_config', + variants: { + on: { key: 'on' }, + off: { key: 'off' }, + }, + segments: [ + { + metadata: { segmentName: 'multiple condition trigger' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'xdfrewd', + allocations: [ + { + range: [0, 99], + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event', 'event_type'], + op: 'is', + values: ['Sign In'], + }, + ], + [ + { + selector: ['context', 'user', 'user_properties', 'name'], + op: 'is', + values: ['Banana'], + }, + ], + ], + }, + { + variant: 'off', + }, + ], +}; diff --git a/packages/targeting/test/flag-config-data/multiple-events.ts b/packages/targeting/test/flag-config-data/multiple-events.ts new file mode 100644 index 000000000..1e96f4125 --- /dev/null +++ b/packages/targeting/test/flag-config-data/multiple-events.ts @@ -0,0 +1,41 @@ +import { TargetingFlag } from '../../src/typings/targeting'; + +export const flagConfigMultipleEvents: TargetingFlag = { + key: 'sr_targeting_config', + variants: { + on: { key: 'on' }, + off: { key: 'off' }, + }, + segments: [ + { + metadata: { segmentName: 'multiple event trigger' }, + bucket: { + selector: ['context', 'session_id'], + salt: 'xdfrewd', + allocations: [ + { + range: [0, 99], + distributions: [ + { + variant: 'on', + range: [0, 42949673], + }, + ], + }, + ], + }, + conditions: [ + [ + { + selector: ['context', 'event_types'], + op: 'set contains', + values: ['Add to Cart', 'Sign In'], + }, + ], + ], + }, + { + variant: 'off', + }, + ], +}; diff --git a/packages/targeting/test/flag-config-data/user-props.ts b/packages/targeting/test/flag-config-data/user-props.ts new file mode 100644 index 000000000..e1714f48b --- /dev/null +++ b/packages/targeting/test/flag-config-data/user-props.ts @@ -0,0 +1,45 @@ +import { TargetingFlag } from '../../src/typings/targeting'; + +export const flagUserProps: TargetingFlag = { + key: 'sr_targeting_config', + variants: { + off: { + key: 'off', + }, + on: { + key: 'on', + }, + }, + segments: [ + { + metadata: { + segmentName: 'Segment 1', + segmentId: 'uuid1', + }, + bucket: { + selector: ['context', 'session_id'], + salt: 'sphIslYm', + allocations: [ + { + distributions: [ + { + range: [0, 42949673], + variant: 'on', + }, + ], + range: [0, 51], + }, + ], + }, + conditions: [ + [ + { + op: 'contains', + selector: ['context', 'user', 'user_properties', 'plan_id'], + values: ['paid', 'free'], + }, + ], + ], + }, + ], +}; diff --git a/packages/targeting/test/jest-setup.js b/packages/targeting/test/jest-setup.js new file mode 100644 index 000000000..65f13b53b --- /dev/null +++ b/packages/targeting/test/jest-setup.js @@ -0,0 +1,8 @@ +require('fake-indexeddb/auto'); +const { IDBFactory } = require('fake-indexeddb'); +const structuredClone = require('@ungap/structured-clone'); + +global.structuredClone = structuredClone.default; +global.beforeEach(() => { + global.indexedDB = new IDBFactory(); +}); diff --git a/packages/targeting/test/targeting-factory.test.ts b/packages/targeting/test/targeting-factory.test.ts new file mode 100644 index 000000000..8a9f2f489 --- /dev/null +++ b/packages/targeting/test/targeting-factory.test.ts @@ -0,0 +1,9 @@ +import targeting from '../src/targeting-factory'; + +describe('targeting factory', () => { + describe('targeting instance', () => { + test('return an instance of the targeting class', async () => { + expect(Object.keys(targeting)).toEqual(['evaluateTargeting']); + }); + }); +}); diff --git a/packages/targeting/test/targeting-idb-store.test.ts b/packages/targeting/test/targeting-idb-store.test.ts new file mode 100644 index 000000000..a08951936 --- /dev/null +++ b/packages/targeting/test/targeting-idb-store.test.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Logger } from '@amplitude/analytics-types'; +import { IDBPDatabase } from 'idb'; +import { TargetingDB, targetingIDBStore } from '../src/targeting-idb-store'; + +type MockedLogger = jest.Mocked; + +const mockLoggerProvider: MockedLogger = { + error: jest.fn(), + log: jest.fn(), + disable: jest.fn(), + enable: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}; + +describe('targeting idb store', () => { + let db: IDBPDatabase; + beforeEach(async () => { + db = await targetingIDBStore.openOrCreateDB('static_key'); + await db.clear('eventTypesForSession'); + }); + describe('storeEventTypeForSession', () => { + test('should add event to stored event list', async () => { + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Purchase', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const allEntries = await db.getAll('eventTypesForSession'); + expect(allEntries).toEqual([ + { + eventTypes: { + 'Add to Cart': { 123: { event_type: 'Add to Cart' } }, + Purchase: { 123: { event_type: 'Purchase' } }, + }, + sessionId: 123, + }, + ]); + }); + + test('should handle adding the same event twice correctly', async () => { + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const allEntries = await db.getAll('eventTypesForSession'); + expect(allEntries).toEqual([ + { + eventTypes: { + 'Add to Cart': { 123: { event_type: 'Add to Cart' } }, + }, + sessionId: 123, + }, + ]); + }); + + test('should add the same event with different timestamps', async () => { + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 456, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const allEntries = await db.getAll('eventTypesForSession'); + expect(allEntries).toEqual([ + { + eventTypes: { + 'Add to Cart': { 123: { event_type: 'Add to Cart' }, 456: { event_type: 'Add to Cart' } }, + }, + sessionId: 123, + }, + ]); + }); + + test('should return updated list', async () => { + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const updatedList = await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Purchase', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(updatedList).toEqual({ + 'Add to Cart': { '123': { event_type: 'Add to Cart' } }, + Purchase: { '123': { event_type: 'Purchase' } }, + }); + }); + + test('should handle errors', async () => { + jest.spyOn(targetingIDBStore, 'updateEventListForSession').mockRejectedValueOnce('error'); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); + }); + + test('should handle errors on updating event list', async () => { + const mockDB: IDBPDatabase = { + transaction: jest.fn().mockImplementation(() => { + return { + store: { + put: jest.fn().mockImplementation(() => Promise.reject('put error')), + }, + }; + }), + } as unknown as IDBPDatabase; + jest.spyOn(targetingIDBStore, 'openOrCreateDB').mockResolvedValue(mockDB); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(mockLoggerProvider.warn).toHaveBeenCalled(); + }); + + test('should handle undefined transaction', async () => { + const mockDB: IDBPDatabase = { + transaction: jest.fn().mockImplementation(() => undefined), + } as unknown as IDBPDatabase; + jest.spyOn(targetingIDBStore, 'openOrCreateDB').mockResolvedValue(mockDB); + const updatedEventTypes = await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(updatedEventTypes).toBeUndefined(); + }); + + test('should delete old sessions', async () => { + // Set current time to 08:30 + jest.useFakeTimers().setSystemTime(new Date('2023-07-31 08:30:00').getTime()); + // Insert older session + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Add to Cart', + sessionId: new Date('2023-07-25 08:30:00').getTime(), + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const allEntries = await db.getAll('eventTypesForSession'); + expect(allEntries).toEqual([ + { + eventTypes: { 'Add to Cart': { 123: { event_type: 'Add to Cart' } } }, + sessionId: new Date('2023-07-25 08:30:00').getTime(), + }, + ]); + await targetingIDBStore.storeEventTypeForSession({ + eventTime: 123, + eventType: 'Purchase', + sessionId: new Date('2023-07-31 07:30:00').getTime(), + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + const allEntriesUpdated = await db.getAll('eventTypesForSession'); + expect(allEntriesUpdated).toEqual([ + { + eventTypes: { Purchase: { 123: { event_type: 'Purchase' } } }, + sessionId: new Date('2023-07-31 07:30:00').getTime(), + }, + ]); + }); + }); +}); diff --git a/packages/targeting/test/targeting.test.ts b/packages/targeting/test/targeting.test.ts new file mode 100644 index 000000000..006001e3c --- /dev/null +++ b/packages/targeting/test/targeting.test.ts @@ -0,0 +1,177 @@ +import { Logger } from '@amplitude/analytics-types'; +import { Targeting } from '../src/targeting'; +import { targetingIDBStore } from '../src/targeting-idb-store'; +import { flagCatchAll } from './flag-config-data/catch-all'; +import { flagEventProps } from './flag-config-data/event-props'; +import { flagConfigMultipleConditions } from './flag-config-data/multiple-conditions'; +import { flagConfigMultipleEvents } from './flag-config-data/multiple-events'; +import { flagUserProps } from './flag-config-data/user-props'; + +type MockedLogger = jest.Mocked; +const mockEvent = { + event_type: 'sign_in', + time: 123, +}; +const mockUserProperties = {}; + +const mockLoggerProvider: MockedLogger = { + error: jest.fn(), + log: jest.fn(), + disable: jest.fn(), + enable: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}; +describe('targeting', () => { + describe('evaluateTargeting', () => { + test('should call evaluation engine evaluate', async () => { + const targeting = new Targeting(); + const mockEngineEvaluate = jest.fn(); + targeting.evaluationEngine.evaluate = mockEngineEvaluate; + await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: mockUserProperties, + flag: flagCatchAll, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(mockEngineEvaluate).toHaveBeenCalledWith( + { + user: { + device_id: '1a2b3c', + user_properties: mockUserProperties, + }, + session_id: 123, + event_types: undefined, + }, + [flagCatchAll], + ); + }); + test('should pass a list of event types to the evaluation engine evaluate', async () => { + jest.spyOn(targetingIDBStore, 'storeEventTypeForSession').mockResolvedValueOnce({ + 'Add to Cart': { 123: { event_type: 'Add to Cart' } }, + Purchase: { 123: { event_type: 'Purchase' } }, + }); + const targeting = new Targeting(); + const mockEngineEvaluate = jest.fn(); + targeting.evaluationEngine.evaluate = mockEngineEvaluate; + await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + event: mockEvent, + userProperties: mockUserProperties, + flag: flagCatchAll, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(mockEngineEvaluate).toHaveBeenCalledWith( + { + event: mockEvent, + user: { + device_id: '1a2b3c', + user_properties: mockUserProperties, + }, + session_id: 123, + event_types: ['Add to Cart', 'Purchase'], + }, + [flagCatchAll], + ); + }); + }); + + describe('condition tests', () => { + test('should work with event properties', async () => { + const targeting = new Targeting(); + const targetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: mockUserProperties, + flag: flagEventProps, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + event: { + event_type: 'Purchase', + event_properties: { + '[Amplitude] Page URL': 'http://localhost:3000/tasks-app', + }, + }, + }); + expect(targetingBucket['sr_targeting_config'].key).toEqual('on'); + }); + test('should work with a user property condition', async () => { + const targeting = new Targeting(); + const targetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: { + plan_id: 'paid', + }, + flag: flagUserProps, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(targetingBucket['sr_targeting_config'].key).toEqual('on'); + }); + test('should work with multiple events', async () => { + const targeting = new Targeting(); + const targetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: {}, + event: { + event_type: 'Add to Cart', + time: 123, + }, + flag: flagConfigMultipleEvents, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + // Should not match with only one event + expect(targetingBucket['sr_targeting_config'].key).toEqual('off'); + const updatedTargetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: {}, + event: { + event_type: 'Sign In', + time: 123, + }, + flag: flagConfigMultipleEvents, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + console.log('targetingBucket', targetingBucket); + expect(updatedTargetingBucket['sr_targeting_config'].key).toEqual('on'); + }); + test('should work with multiple conditions in a segment - user property and event', async () => { + const targeting = new Targeting(); + // Only user properties match the flag config here, bucket should be on + const userPropertyTargetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: { + name: 'Banana', + }, + flag: flagConfigMultipleConditions, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(userPropertyTargetingBucket['sr_targeting_config'].key).toEqual('on'); + // Only the event type matches the flag config here, bucket should be on + const eventTargetingBucket = await targeting.evaluateTargeting({ + deviceId: '1a2b3c', + userProperties: {}, + event: { + event_type: 'Sign In', + time: 123, + }, + flag: flagConfigMultipleConditions, + sessionId: 123, + apiKey: 'static_key', + loggerProvider: mockLoggerProvider, + }); + expect(eventTargetingBucket['sr_targeting_config'].key).toEqual('on'); + }); + }); +}); diff --git a/packages/targeting/tsconfig.es5.json b/packages/targeting/tsconfig.es5.json new file mode 100644 index 000000000..77e041d3f --- /dev/null +++ b/packages/targeting/tsconfig.es5.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "commonjs", + "noEmit": false, + "outDir": "lib/cjs", + "rootDir": "./src" + } +} diff --git a/packages/targeting/tsconfig.esm.json b/packages/targeting/tsconfig.esm.json new file mode 100644 index 000000000..bec981eee --- /dev/null +++ b/packages/targeting/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "es6", + "noEmit": false, + "outDir": "lib/esm", + "rootDir": "./src" + } +} diff --git a/packages/targeting/tsconfig.json b/packages/targeting/tsconfig.json new file mode 100644 index 000000000..aa2d90b95 --- /dev/null +++ b/packages/targeting/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "lib": ["dom", "es2020"], + "noEmit": true, + "rootDir": "." + } +} diff --git a/tsconfig.json b/tsconfig.json index 0b3f41a98..34a827ffd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "downlevelIteration": true, "inlineSources": true, "importHelpers": true, - "lib": ["es6"], + "lib": ["es2021", "dom"], "module": "es2020", "moduleResolution": "node", "noEmitHelpers": true, @@ -22,6 +22,6 @@ "strict": true, "strictBindCallApply": true, "target": "es5", - "types": ["jest", "node"], + "types": ["jest", "node", "idb"], } } diff --git a/yarn.lock b/yarn.lock index 92f7159a9..14c88611f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,6 +30,13 @@ resolved "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-1.3.5.tgz" integrity sha512-IpncCNTZZ6VoGe4fNwTTZtpi+ZNm3mtsocdbCHtIwmKg2wmOF2E09CAwvyF7mK5aRlMIrSAKQyR3GwraATghSw== +"@amplitude/experiment-core@0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz#f94219d68d86322e8d580c8fbe0672dcd29f86bb" + integrity sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA== + dependencies: + js-base64 "^3.7.5" + "@amplitude/experiment-core@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.11.0.tgz" @@ -4533,6 +4540,11 @@ resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz" integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== +"@types/core-js@^2.5.8": + version "2.5.8" + resolved "https://registry.yarnpkg.com/@types/core-js/-/core-js-2.5.8.tgz#d5c6ec44f2f3328653dce385ae586bd8261f8e85" + integrity sha512-VgnAj6tIAhJhZdJ8/IpxdatM8G4OD3VWGlp6xIxUGENZlpbob9Ty4VVdC1FIEp0aK6DBscDDjyzy5FB60TuNqg== + "@types/css-font-loading-module@0.0.7": version "0.0.7" resolved "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz"