diff --git a/packages/plugin-session-replay-react-native/README.md b/packages/plugin-session-replay-react-native/README.md index 57d4e7aa0..01c9515f3 100644 --- a/packages/plugin-session-replay-react-native/README.md +++ b/packages/plugin-session-replay-react-native/README.md @@ -12,7 +12,7 @@ npm install @amplitude/plugin-session-replay-react-native Add the session replay plugin to your Amplitude instance as follows ```js -import { SessionReplayPlugin } from '@amplitude/plugin-session-replay-react-native'; +import { SessionReplayPlugin, MaskLevel } from '@amplitude/plugin-session-replay-react-native'; // ... @@ -20,15 +20,35 @@ const config: SessionReplayConfig = { enableRemoteConfig: true, // default true sampleRate: 1, // default 0 logLevel: LogLevel.Warn, // default LogLevel.Warn + privacyConfig: { maskLevel: MaskLevel.Medium }, // default MaskLevel.Medium }; await init('YOUR_API_KEY').promise; await add(new SessionReplayPlugin(config)).promise; ``` +## Mask levels + +Control how aggressively Session Replay masks sensitive content via the `privacyConfig.maskLevel` config option: + +| Value | What gets masked | +|---|---| +| `MaskLevel.Light` | Password and phone-number `` fields only | +| `MaskLevel.Medium` (default) | All `` fields | +| `MaskLevel.Conservative` | All `` fields **and** all `` elements | + +```js +import { SessionReplayPlugin, MaskLevel } from '@amplitude/plugin-session-replay-react-native'; + +const config: SessionReplayConfig = { + privacyConfig: { maskLevel: MaskLevel.Conservative }, // mask all text and inputs +}; +``` + +> **Note:** Third-party text renderers that bypass UIKit/Android's standard text views (for example `react-native-svg`, `@shopify/react-native-skia`) are not detected by automatic masking. Wrap such content in `` to mask it manually. ## Masking views -To maks certain views, add the `AmpMaskView` tag with the mask property `amp-mask` around the section to be masked +To mask certain views, add the `AmpMaskView` tag with the mask property `amp-mask` around the section to be masked ```js import { AmpMaskView } from '@amplitude/plugin-session-replay-react-native'; diff --git a/packages/plugin-session-replay-react-native/android/src/main/java/com/amplitude/pluginsessionreplayreactnative/PluginSessionReplayReactNativeModule.kt b/packages/plugin-session-replay-react-native/android/src/main/java/com/amplitude/pluginsessionreplayreactnative/PluginSessionReplayReactNativeModule.kt index ae709704e..33181e798 100644 --- a/packages/plugin-session-replay-react-native/android/src/main/java/com/amplitude/pluginsessionreplayreactnative/PluginSessionReplayReactNativeModule.kt +++ b/packages/plugin-session-replay-react-native/android/src/main/java/com/amplitude/pluginsessionreplayreactnative/PluginSessionReplayReactNativeModule.kt @@ -1,6 +1,8 @@ package com.amplitude.pluginsessionreplayreactnative import com.amplitude.android.sessionreplay.SessionReplay +import com.amplitude.android.sessionreplay.config.MaskLevel +import com.amplitude.android.sessionreplay.config.PrivacyConfig import com.amplitude.common.Logger import com.amplitude.common.android.LogcatLogger import com.amplitude.core.ServerZone @@ -20,7 +22,7 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic } @ReactMethod - fun setup(apiKey: String, deviceId: String?, sessionId: Double, serverZone: String?, sampleRate: Double, enableRemoteConfig: Boolean, logLevel: Int, autoStart: Boolean) { + fun setup(apiKey: String, deviceId: String?, sessionId: Double, serverZone: String?, sampleRate: Double, enableRemoteConfig: Boolean, logLevel: Int, autoStart: Boolean, maskLevel: String) { LogcatLogger.logger.logMode = when (logLevel) { 0 -> Logger.LogMode.OFF 1 -> Logger.LogMode.ERROR @@ -30,6 +32,13 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic else -> Logger.LogMode.WARN } + val mappedMaskLevel = when (maskLevel.lowercase()) { + "light" -> MaskLevel.LIGHT + "medium" -> MaskLevel.MEDIUM + "conservative" -> MaskLevel.CONSERVATIVE + else -> MaskLevel.MEDIUM + } + LogcatLogger.logger.debug(""" setup: API Key: $apiKey @@ -40,6 +49,7 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic Enable Remote Config: $enableRemoteConfig Log Level: $logLevel Auto Start: $autoStart + Mask Level: $maskLevel """.trimIndent()) sessionReplay = SessionReplay( @@ -54,7 +64,8 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic "EU" -> ServerZone.EU else -> ServerZone.US }, - autoStart = autoStart + autoStart = autoStart, + privacyConfig = PrivacyConfig(maskLevel = mappedMaskLevel) ) } @@ -87,7 +98,7 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic } promise.resolve(map) } - + @ReactMethod fun start() { sessionReplay.start() diff --git a/packages/plugin-session-replay-react-native/example/App.tsx b/packages/plugin-session-replay-react-native/example/App.tsx index 4fa2da65f..7a00c03bb 100644 --- a/packages/plugin-session-replay-react-native/example/App.tsx +++ b/packages/plugin-session-replay-react-native/example/App.tsx @@ -42,7 +42,7 @@ import { import { LogLevel } from '@amplitude/analytics-types'; import { NavigationContainer } from '@react-navigation/native'; -import { SessionReplayPlugin, AmpMaskView, SessionReplayConfig } from '@amplitude/plugin-session-replay-react-native'; +import { SessionReplayPlugin, AmpMaskView, SessionReplayConfig, MaskLevel } from '@amplitude/plugin-session-replay-react-native'; import { createNativeStackNavigator, type NativeStackScreenProps, @@ -285,7 +285,8 @@ function App(): React.JSX.Element { const config: SessionReplayConfig = { enableRemoteConfig: false, sampleRate: 1, - logLevel: LogLevel.Debug + logLevel: LogLevel.Debug, + privacyConfig: { maskLevel: MaskLevel.Medium }, }; (async () => { await init('YOUR-API-KEY', 'example_user_id', { diff --git a/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.mm b/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.mm index 54d9375b6..f17764ee4 100644 --- a/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.mm +++ b/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.mm @@ -2,7 +2,7 @@ @interface RCT_EXTERN_MODULE(PluginSessionReplayReactNative, NSObject) -RCT_EXTERN_METHOD(setup:(NSString)apiKey deviceId:(NSString)deviceId sessionId:(nonnull NSNumber)sessionId serverZone:(NSString)serverZone sampleRate:(float)sampleRate enableRemoteConfig:(BOOL)enableRemoteConfig logLevel:(int)logLevel autoStart:(BOOL)autoStart resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setup:(NSString)apiKey deviceId:(NSString)deviceId sessionId:(nonnull NSNumber)sessionId serverZone:(NSString)serverZone sampleRate:(float)sampleRate enableRemoteConfig:(BOOL)enableRemoteConfig logLevel:(int)logLevel autoStart:(BOOL)autoStart maskLevel:(NSString)maskLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(setSessionId:(nonnull NSNumber)sessionId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.swift b/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.swift index d03d309eb..7d36616f3 100644 --- a/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.swift +++ b/packages/plugin-session-replay-react-native/ios/PluginSessionReplayReactNative.swift @@ -6,7 +6,7 @@ class PluginSessionReplayReactNative: NSObject { var sessionReplay: SessionReplay! - @objc(setup:deviceId:sessionId:serverZone:sampleRate:enableRemoteConfig:logLevel:autoStart:resolve:reject:) + @objc(setup:deviceId:sessionId:serverZone:sampleRate:enableRemoteConfig:logLevel:autoStart:maskLevel:resolve:reject:) func setup(_ apiKey: String, deviceId: String, sessionId: NSNumber, @@ -15,6 +15,7 @@ class PluginSessionReplayReactNative: NSObject { enableRemoteConfig: Bool, logLevel: Int, autoStart: Bool, + maskLevel: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void { print( @@ -28,6 +29,7 @@ class PluginSessionReplayReactNative: NSObject { Enable Remote Config: \(enableRemoteConfig) Log Level: \(logLevel) Auto Start: \(autoStart) + Mask Level: \(maskLevel) """ ) sessionReplay = SessionReplay(apiKey:apiKey, @@ -36,6 +38,7 @@ class PluginSessionReplayReactNative: NSObject { sampleRate: sampleRate, logger:ConsoleLogger(logLevel: logLevel), serverZone: serverZone == "EU" ? .EU : .US, + maskLevel: .fromString(maskLevel), enableRemoteConfig: enableRemoteConfig) if (autoStart) { sessionReplay.start() @@ -90,3 +93,18 @@ class PluginSessionReplayReactNative: NSObject { resolve(nil) } } + +extension MaskLevel { + static func fromString(_ input: String) -> MaskLevel { + switch input.lowercased() { + case "light": + return .light + case "medium": + return .medium + case "conservative": + return .conservative + default: + return .medium + } + } +} diff --git a/packages/plugin-session-replay-react-native/jest.config.js b/packages/plugin-session-replay-react-native/jest.config.js index dcc1a7c7a..84abbaa4d 100644 --- a/packages/plugin-session-replay-react-native/jest.config.js +++ b/packages/plugin-session-replay-react-native/jest.config.js @@ -10,6 +10,11 @@ module.exports = { modulePathIgnorePatterns: [ "/lib/" ], + testPathIgnorePatterns: [ + ...(baseConfig.testPathIgnorePatterns || []), + '/example/', + ], + moduleFileExtensions: ['tsx', 'ts', 'js', 'jsx', 'json'], transformIgnorePatterns: [ 'node_modules/(?!(.pnpm|@react-native|react-native|@segment)/)', ], diff --git a/packages/plugin-session-replay-react-native/src/index.tsx b/packages/plugin-session-replay-react-native/src/index.tsx index 555ad80b4..53363d010 100644 --- a/packages/plugin-session-replay-react-native/src/index.tsx +++ b/packages/plugin-session-replay-react-native/src/index.tsx @@ -1,5 +1,4 @@ export { SessionReplayPlugin } from './session-replay'; +export { type SessionReplayConfig, MaskLevel, PrivacyConfig } from './session-replay-config'; export { AmpMaskView } from './app-mask-view'; - -export { SessionReplayConfig } from './session-replay-config'; diff --git a/packages/plugin-session-replay-react-native/src/session-replay-config.ts b/packages/plugin-session-replay-react-native/src/session-replay-config.ts index 48940724e..85298f4ad 100644 --- a/packages/plugin-session-replay-react-native/src/session-replay-config.ts +++ b/packages/plugin-session-replay-react-native/src/session-replay-config.ts @@ -1,17 +1,78 @@ import { LogLevel } from '@amplitude/analytics-types'; +/** + * Masking levels for sensitive content in session replay + */ +export enum MaskLevel { + /** + * Light masking - minimal content is masked + */ + Light = 'light', + /** + * Medium masking - balanced approach to content masking + */ + Medium = 'medium', + /** + * Conservative masking - maximum content masking for privacy + */ + Conservative = 'conservative', +} + +export interface PrivacyConfig { + maskLevel?: MaskLevel; +} + +/** + * Configuration for the Session Replay React Native plugin. + * + * Unlike the standalone `@amplitude/session-replay-react-native` SDK, the + * plugin auto-sources `apiKey`, `deviceId`, `sessionId`, and `serverZone` + * from the analytics client's `ReactNativeConfig` at `setup()` time, so + * those fields are intentionally absent from the public plugin config. + * The plugin also never shipped a deprecated top-level `maskLevel`, so no + * input-boundary normalization (and no `SessionReplayConfigInternal` alias) + * is needed here. + */ export interface SessionReplayConfig { - sampleRate?: number; + /** + * Whether to automatically start recording when the plugin is added + * @default true + */ + autoStart?: boolean; + + /** + * Whether to enable remote configuration + * @default true + */ enableRemoteConfig?: boolean; + + /** + * Log level for the SDK + * @default LogLevel.Warn + */ logLevel?: LogLevel; - autoStart?: boolean; + + /** + * Privacy configuration for session replay + * @default { maskLevel: MaskLevel.Medium } + */ + privacyConfig?: PrivacyConfig; + + /** + * Sample rate for session replay (0.0 to 1.0) + * Determines what percentage of sessions will be recorded + * @default 0 + */ + sampleRate?: number; } -export const getDefaultConfig: () => SessionReplayConfig = () => { +export const getDefaultConfig: () => Required = () => { return { - sampleRate: 0, + autoStart: true, enableRemoteConfig: true, logLevel: LogLevel.Warn, - autoStart: true, + privacyConfig: { maskLevel: MaskLevel.Medium }, + sampleRate: 0, }; }; +export { LogLevel }; diff --git a/packages/plugin-session-replay-react-native/src/session-replay.ts b/packages/plugin-session-replay-react-native/src/session-replay.ts index ef50f236f..55efc3f92 100644 --- a/packages/plugin-session-replay-react-native/src/session-replay.ts +++ b/packages/plugin-session-replay-react-native/src/session-replay.ts @@ -8,7 +8,8 @@ import type { EnrichmentPlugin, Event, ReactNativeClient, ReactNativeConfig } fr import { PluginSessionReplayReactNative } from './native-module'; import { VERSION } from './version'; import { SessionReplayConfig, getDefaultConfig } from './session-replay-config'; -import { LogLevel } from '@amplitude/analytics-types'; + +type ResolvedSessionReplayConfig = Required; export class SessionReplayPlugin implements EnrichmentPlugin { name = '@amplitude/plugin-session-replay-react-native'; @@ -19,7 +20,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin { this.config = config; console.log(`Installing @amplitude/plugin-session-replay-react-native, version ${VERSION}.`); + // `apiKey`, `deviceId`, `sessionId`, and `serverZone` are sourced from the + // analytics client's `ReactNativeConfig` because the plugin runs inside an + // initialized Amplitude SDK and inherits identity from it. + // `privacyConfig.maskLevel` is guaranteed to be set by the merge in the + // constructor: `getDefaultConfig()` supplies `{ maskLevel: Medium }` and a + // user-supplied `privacyConfig` always carries a `maskLevel`. + const { privacyConfig } = this.sessionReplayConfig; await PluginSessionReplayReactNative.setup( config.apiKey, config.deviceId, config.sessionId, config.serverZone, - this.sessionReplayConfig.sampleRate ?? 1, - this.sessionReplayConfig.enableRemoteConfig ?? true, - this.sessionReplayConfig.logLevel ?? LogLevel.Warn, - this.sessionReplayConfig.autoStart ?? true, + this.sessionReplayConfig.sampleRate, + this.sessionReplayConfig.enableRemoteConfig, + this.sessionReplayConfig.logLevel, + this.sessionReplayConfig.autoStart, + // TODO(SDKRN-15): Migrate native bridge to accept the full privacyConfig object instead of a flat maskLevel string. + privacyConfig.maskLevel, ); this.isInitialized = true; } diff --git a/packages/plugin-session-replay-react-native/test/index.test.ts b/packages/plugin-session-replay-react-native/test/index.test.ts index dbf5ade15..26c457eae 100644 --- a/packages/plugin-session-replay-react-native/test/index.test.ts +++ b/packages/plugin-session-replay-react-native/test/index.test.ts @@ -1,9 +1,102 @@ -import { getDefaultConfig } from '../src/session-replay-config'; +import { LogLevel } from '@amplitude/analytics-types'; + +const nativeSetupMock = jest.fn((..._args: unknown[]) => Promise.resolve()); + +jest.mock('../src/native-module', () => ({ + PluginSessionReplayReactNative: { + setup: nativeSetupMock, + setSessionId: jest.fn(() => Promise.resolve()), + getSessionId: jest.fn(() => Promise.resolve(0)), + getSessionReplayProperties: jest.fn(() => Promise.resolve({})), + start: jest.fn(() => Promise.resolve()), + stop: jest.fn(() => Promise.resolve()), + teardown: jest.fn(() => Promise.resolve()), + }, +})); + +import { getDefaultConfig, MaskLevel } from '../src/session-replay-config'; +import { SessionReplayPlugin } from '../src/session-replay'; describe('Session Replay default config', () => { - // write a test that would check the default config for session replay plugin it('should have autostart default to true', () => { const config = getDefaultConfig(); expect(config.autoStart).toBe(true); }); + + it('should have privacyConfig.maskLevel default to Medium', () => { + const config = getDefaultConfig(); + expect(config.privacyConfig?.maskLevel).toBe(MaskLevel.Medium); + }); +}); + +describe('MaskLevel enum values', () => { + it('exposes the three expected masking levels', () => { + expect(MaskLevel.Light).toBe('light'); + expect(MaskLevel.Medium).toBe('medium'); + expect(MaskLevel.Conservative).toBe('conservative'); + }); +}); + +describe('SessionReplayPlugin.setup forwards maskLevel to native', () => { + const baseConfig = { + apiKey: 'test-api-key', + deviceId: 'test-device', + sessionId: 12345, + serverZone: 'US' as const, + }; + + beforeEach(() => { + nativeSetupMock.mockClear(); + }); + + const setupArgs = () => nativeSetupMock.mock.calls[0]; + + it('forwards Conservative as the 9th positional argument', async () => { + const plugin = new SessionReplayPlugin({ privacyConfig: { maskLevel: MaskLevel.Conservative } }); + await plugin.setup(baseConfig as never, {} as never); + expect(nativeSetupMock).toHaveBeenCalledTimes(1); + expect(setupArgs()[8]).toBe(MaskLevel.Conservative); + expect(setupArgs()[8]).toBe('conservative'); + }); + + it('forwards Light as the 9th positional argument', async () => { + const plugin = new SessionReplayPlugin({ privacyConfig: { maskLevel: MaskLevel.Light } }); + await plugin.setup(baseConfig as never, {} as never); + expect(setupArgs()[8]).toBe(MaskLevel.Light); + expect(setupArgs()[8]).toBe('light'); + }); + + it('forwards Medium as the 9th positional argument', async () => { + const plugin = new SessionReplayPlugin({ privacyConfig: { maskLevel: MaskLevel.Medium } }); + await plugin.setup(baseConfig as never, {} as never); + expect(setupArgs()[8]).toBe(MaskLevel.Medium); + expect(setupArgs()[8]).toBe('medium'); + }); + + it('defaults to Medium when maskLevel is not provided', async () => { + const plugin = new SessionReplayPlugin(); + await plugin.setup(baseConfig as never, {} as never); + expect(setupArgs()[8]).toBe(MaskLevel.Medium); + }); + + it('passes prior positional arguments through unchanged', async () => { + const plugin = new SessionReplayPlugin({ + sampleRate: 0.5, + enableRemoteConfig: false, + logLevel: LogLevel.Debug, + autoStart: false, + privacyConfig: { maskLevel: MaskLevel.Conservative }, + }); + await plugin.setup(baseConfig as never, {} as never); + const args = setupArgs(); + expect(args[0]).toBe('test-api-key'); + expect(args[1]).toBe('test-device'); + expect(args[2]).toBe(12345); + expect(args[3]).toBe('US'); + expect(args[4]).toBe(0.5); + expect(args[5]).toBe(false); + expect(args[6]).toBe(LogLevel.Debug); + expect(args[7]).toBe(false); + expect(args[8]).toBe(MaskLevel.Conservative); + }); }); diff --git a/packages/session-replay-react-native/src/index.tsx b/packages/session-replay-react-native/src/index.tsx index 4776b46a9..ce60c5ef4 100644 --- a/packages/session-replay-react-native/src/index.tsx +++ b/packages/session-replay-react-native/src/index.tsx @@ -8,7 +8,7 @@ export { stop, setDeviceId, } from './session-replay'; -export { type SessionReplayConfig, MaskLevel } from './session-replay-config'; +export { type SessionReplayConfig, MaskLevel, PrivacyConfig } from './session-replay-config'; export { SessionReplayPlugin } from './plugin-session-replay'; export type { SessionReplayPluginConfig } from './plugin-session-replay-config'; diff --git a/packages/session-replay-react-native/src/session-replay-config.ts b/packages/session-replay-react-native/src/session-replay-config.ts index 6ad9ef1d1..1b118b324 100644 --- a/packages/session-replay-react-native/src/session-replay-config.ts +++ b/packages/session-replay-react-native/src/session-replay-config.ts @@ -18,6 +18,10 @@ export enum MaskLevel { Conservative = 'conservative', } +export interface PrivacyConfig { + maskLevel?: MaskLevel; +} + /** * Configuration for Session Replay React Native SDK */ @@ -56,9 +60,16 @@ export interface SessionReplayConfig { /** * Level of masking applied to sensitive content * @default MaskLevel.Medium + * @deprecated Use `privacyConfig.maskLevel` instead. */ maskLevel?: MaskLevel; + /** + * Privacy configuration for session replay + * @default { maskLevel: MaskLevel.Medium } + */ + privacyConfig?: PrivacyConfig; + /** * Whether to opt out of session replay collection * @default false @@ -87,14 +98,25 @@ export interface SessionReplayConfig { sessionId?: number; } -export const getDefaultConfig: () => Required> = () => { +/** + * Internal config shape used by the SDK after the public `SessionReplayConfig` + * is normalized at the input boundary. The deprecated top-level `maskLevel` is + * folded into `privacyConfig` by `normalizeConfig`, so nothing past that point + * needs to know the deprecated field ever existed. + * + * Not exported from `index.tsx` on purpose — this is an implementation detail + * of the session replay module. + */ +export type SessionReplayConfigInternal = Omit; + +export const getDefaultConfig: () => Required> = () => { return { autoStart: true, deviceId: null, enableRemoteConfig: true, logLevel: LogLevel.Warn, - maskLevel: MaskLevel.Medium, optOut: false, + privacyConfig: { maskLevel: MaskLevel.Medium }, sampleRate: 0, serverZone: 'US', sessionId: -1, diff --git a/packages/session-replay-react-native/src/session-replay.ts b/packages/session-replay-react-native/src/session-replay.ts index 39cecc192..9a6774c4c 100644 --- a/packages/session-replay-react-native/src/session-replay.ts +++ b/packages/session-replay-react-native/src/session-replay.ts @@ -1,11 +1,33 @@ // @refresh reset import { NativeSessionReplay, type NativeSessionReplayConfig } from './native-module'; -import { getDefaultConfig, SessionReplayConfig } from './session-replay-config'; +import { getDefaultConfig, SessionReplayConfig, SessionReplayConfigInternal } from './session-replay-config'; import { createSessionReplayLogger } from './logger'; import { VERSION } from './version'; -let fullConfig: Required | null = null; +type ResolvedSessionReplayConfig = Required; + +/** + * Translates the public `SessionReplayConfig` into the internal shape by + * folding the deprecated top-level `maskLevel` into `privacyConfig`. After + * this step, the rest of the SDK only ever sees `privacyConfig`. + * + * `privacyConfig` wins when explicitly set; otherwise translate the + * deprecated `maskLevel`; otherwise leave the field out so the default + * supplied by `getDefaultConfig()` survives the shallow merge in `init()`. + */ +function normalizeConfig(config: SessionReplayConfig): SessionReplayConfigInternal { + const { maskLevel, privacyConfig, ...rest } = config; + if (privacyConfig !== undefined) { + return { ...rest, privacyConfig }; + } + if (maskLevel !== undefined) { + return { ...rest, privacyConfig: { maskLevel } }; + } + return rest; +} + +let fullConfig: ResolvedSessionReplayConfig | null = null; let isInitialized = false; let logger = createSessionReplayLogger(); @@ -33,9 +55,13 @@ export async function init(config: SessionReplayConfig): Promise { return; } + // TODO: this is a shallow merge — a user-supplied `privacyConfig` replaces + // the default object wholesale. That's fine while `PrivacyConfig` only + // carries `maskLevel`, but if it ever grows more fields a deeper merge will + // be needed so partial user configs don't drop defaults. fullConfig = { ...getDefaultConfig(), - ...config, + ...normalizeConfig(config), }; logger.setLogLevel(fullConfig.logLevel); @@ -206,11 +232,18 @@ export async function stop(): Promise { await NativeSessionReplay.stop(); } -function nativeConfig(config: Required): NativeSessionReplayConfig { +function nativeConfig(config: ResolvedSessionReplayConfig): NativeSessionReplayConfig { + // `privacyConfig.maskLevel` is guaranteed to be set by the merge in + // `init()`: `getDefaultConfig()` supplies `{ maskLevel: Medium }` and the + // shallow spread only overwrites it with a normalized `privacyConfig` that + // carries a `maskLevel`. Strip `privacyConfig` from the spread because the + // native bridge only takes a flat `maskLevel` string. + const { privacyConfig, ...rest } = config; return { - ...config, - logLevel: config.logLevel as NativeSessionReplayConfig['logLevel'], - maskLevel: config.maskLevel.toString() as NativeSessionReplayConfig['maskLevel'], + ...rest, + logLevel: rest.logLevel as NativeSessionReplayConfig['logLevel'], + // TODO(SDKRN-15): Migrate native bridge to accept the full privacyConfig object instead of a flat maskLevel string. + maskLevel: privacyConfig.maskLevel as NativeSessionReplayConfig['maskLevel'], }; } diff --git a/packages/session-replay-react-native/test/session-replay.test.ts b/packages/session-replay-react-native/test/session-replay.test.ts index 45a1ca49c..359261e53 100644 --- a/packages/session-replay-react-native/test/session-replay.test.ts +++ b/packages/session-replay-react-native/test/session-replay.test.ts @@ -3,18 +3,25 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-var-requires */ -// Use the mock from __mocks__ directory jest.mock('react-native'); -// Explicitly mock the logger module using the imported mock jest.mock('../src/logger', () => require('./utils/logger')); -import { init, start, stop, getSessionId, getSessionReplayProperties, type SessionReplayConfig } from '../src/index'; +import { + init, + start, + stop, + getSessionId, + getSessionReplayProperties, + MaskLevel, + type SessionReplayConfig, +} from '../src/index'; import { NativeModules } from 'react-native'; import { LogLevel } from '@amplitude/analytics-types'; -// Mock the getSessionReplayProperties return value for our tests const mockNativeModules = NativeModules as jest.Mocked; mockNativeModules.AMPNativeSessionReplay.getSessionReplayProperties.mockResolvedValue({ replayId: 'test-id' }); @@ -31,7 +38,6 @@ describe('Session Replay Integration Tests', () => { logLevel: LogLevel.Warn, }; - // Complete workflow test await init(testConfig); expect(mockNativeModules.AMPNativeSessionReplay.setup).toHaveBeenCalledWith( expect.objectContaining({ @@ -55,7 +61,6 @@ describe('Session Replay Integration Tests', () => { await stop(); expect(mockNativeModules.AMPNativeSessionReplay.stop).toHaveBeenCalled(); - // Verify calls were made in sequence const calls = jest.mocked(mockNativeModules.AMPNativeSessionReplay); expect(calls.setup).toHaveBeenCalled(); expect(calls.start).toHaveBeenCalled(); @@ -63,4 +68,69 @@ describe('Session Replay Integration Tests', () => { expect(calls.getSessionReplayProperties).toHaveBeenCalled(); expect(calls.stop).toHaveBeenCalled(); }); + + // These tests cover the resolution chain in `nativeConfig()` for the + // deprecated top-level `maskLevel` field alongside `privacyConfig.maskLevel`. + // `init()` keeps `isInitialized` in module scope, so each test uses + // `jest.isolateModules` to get a fresh `init` paired with the fresh + // `react-native` mock instance it actually calls into. + describe('maskLevel resolution', () => { + const runInIsolatedModule = async (config: SessionReplayConfig): Promise => { + let setupMock!: jest.Mock; + let pending!: Promise; + jest.isolateModules(() => { + const { init: freshInit } = require('../src/index') as typeof import('../src/index'); + const { NativeModules: freshNativeModules } = require('react-native') as typeof import('react-native'); + setupMock = (freshNativeModules as jest.Mocked).AMPNativeSessionReplay.setup; + pending = freshInit(config); + }); + await pending; + return setupMock; + }; + + it('forwards the deprecated `maskLevel` to the native module when no `privacyConfig` is provided', async () => { + const setupMock = await runInIsolatedModule({ + apiKey: 'test-api-key', + maskLevel: MaskLevel.Conservative, + }); + + expect(setupMock).toHaveBeenCalledWith(expect.objectContaining({ maskLevel: 'conservative' })); + }); + + it('prefers `privacyConfig.maskLevel` over the deprecated `maskLevel` when both are provided', async () => { + const setupMock = await runInIsolatedModule({ + apiKey: 'test-api-key', + maskLevel: MaskLevel.Conservative, + privacyConfig: { maskLevel: MaskLevel.Light }, + }); + + expect(setupMock).toHaveBeenCalledWith(expect.objectContaining({ maskLevel: 'light' })); + }); + + it('defaults to `Medium` when neither `privacyConfig.maskLevel` nor the deprecated `maskLevel` is set', async () => { + const setupMock = await runInIsolatedModule({ + apiKey: 'test-api-key', + }); + + expect(setupMock).toHaveBeenCalledWith(expect.objectContaining({ maskLevel: 'medium' })); + }); + + it('forwards a user-supplied `privacyConfig.maskLevel` to the native module without the default overwriting it', async () => { + const setupMock = await runInIsolatedModule({ + apiKey: 'test-api-key', + privacyConfig: { maskLevel: MaskLevel.Conservative }, + }); + + expect(setupMock).toHaveBeenCalledWith(expect.objectContaining({ maskLevel: 'conservative' })); + }); + + it('does not pass the internal `privacyConfig` object through to the native module', async () => { + const setupMock = await runInIsolatedModule({ + apiKey: 'test-api-key', + privacyConfig: { maskLevel: MaskLevel.Light }, + }); + + expect(setupMock).toHaveBeenCalledWith(expect.not.objectContaining({ privacyConfig: expect.anything() })); + }); + }); });