From 62a74f39484bfd1686dceb8a394d18da99bf0714 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 12:31:23 -0700 Subject: [PATCH 1/7] feat(plugin-session-replay-react-native): pass maskLevel through to native SessionReplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires a new `maskLevel` configuration field through the plugin's JS → ObjC → Swift and JS → Kotlin bridge so consumers of `@amplitude/plugin-session-replay-react-native` can request Light/Medium/Conservative masking from JS. Previously the plugin always defaulted to MEDIUM regardless of JS config because the bridge layers never forwarded the value to the native `SessionReplay` constructor. Brings the plugin variant to parity with `@amplitude/session-replay-react-native`, which already wires this up. Refs SDKRN-9 (parent), implements SDKRN-11. Co-authored-by: Cursor --- .../PluginSessionReplayReactNativeModule.kt | 17 +++- .../ios/PluginSessionReplayReactNative.mm | 2 +- .../ios/PluginSessionReplayReactNative.swift | 20 +++- .../jest.config.js | 5 + .../src/index.tsx | 2 +- .../src/session-replay-config.ts | 20 ++++ .../src/session-replay.ts | 3 +- .../test/index.test.ts | 97 ++++++++++++++++++- 8 files changed, 157 insertions(+), 9 deletions(-) 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/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..c61a5da52 100644 --- a/packages/plugin-session-replay-react-native/src/index.tsx +++ b/packages/plugin-session-replay-react-native/src/index.tsx @@ -2,4 +2,4 @@ export { SessionReplayPlugin } from './session-replay'; export { AmpMaskView } from './app-mask-view'; -export { SessionReplayConfig } from './session-replay-config'; +export { SessionReplayConfig, MaskLevel } 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..adfa16311 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,10 +1,29 @@ 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 SessionReplayConfig { sampleRate?: number; enableRemoteConfig?: boolean; logLevel?: LogLevel; autoStart?: boolean; + maskLevel?: MaskLevel; } export const getDefaultConfig: () => SessionReplayConfig = () => { @@ -13,5 +32,6 @@ export const getDefaultConfig: () => SessionReplayConfig = () => { enableRemoteConfig: true, logLevel: LogLevel.Warn, autoStart: true, + maskLevel: MaskLevel.Medium, }; }; 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..22c59995d 100644 --- a/packages/plugin-session-replay-react-native/src/session-replay.ts +++ b/packages/plugin-session-replay-react-native/src/session-replay.ts @@ -7,7 +7,7 @@ import type { EnrichmentPlugin, Event, ReactNativeClient, ReactNativeConfig } fr import { PluginSessionReplayReactNative } from './native-module'; import { VERSION } from './version'; -import { SessionReplayConfig, getDefaultConfig } from './session-replay-config'; +import { MaskLevel, SessionReplayConfig, getDefaultConfig } from './session-replay-config'; import { LogLevel } from '@amplitude/analytics-types'; export class SessionReplayPlugin implements EnrichmentPlugin { @@ -41,6 +41,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin 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 maskLevel default to Medium', () => { + const config = getDefaultConfig(); + expect(config.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({ 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({ 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({ 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, + 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); + }); }); From 84adcd0b58f60d1b9c33e59456f882951cb46c78 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 14:03:46 -0700 Subject: [PATCH 2/7] docs(plugin-session-replay-react-native): document maskLevel option and mask levels table Co-authored-by: Cursor --- .../README.md | 24 +++++++++++++++++-- .../example/App.tsx | 5 ++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/plugin-session-replay-react-native/README.md b/packages/plugin-session-replay-react-native/README.md index 57d4e7aa0..319db9b2a 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 + 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 `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 = { + 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/example/App.tsx b/packages/plugin-session-replay-react-native/example/App.tsx index 4fa2da65f..9316c7242 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, + maskLevel: MaskLevel.Medium, }; (async () => { await init('YOUR-API-KEY', 'example_user_id', { From 8f74c51d2a8565986a1501d6d3be92233271bb36 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 14:15:50 -0700 Subject: [PATCH 3/7] refactor(plugin-session-replay-react-native): use PrivacyConfig for maskLevel Add PrivacyConfig interface wrapping maskLevel to match Flutter/Android SDK naming. Migrate plugin package from flat maskLevel to privacyConfig.maskLevel. Add PrivacyConfig to standalone package with deprecated maskLevel kept for backwards compat, preferring privacyConfig.maskLevel in the native bridge. Co-authored-by: Cursor --- .../plugin-session-replay-react-native/README.md | 6 +++--- .../example/App.tsx | 2 +- .../plugin-session-replay-react-native/src/index.tsx | 2 +- .../src/session-replay-config.ts | 8 ++++++-- .../src/session-replay.ts | 2 +- .../test/index.test.ts | 12 ++++++------ packages/session-replay-react-native/src/index.tsx | 2 +- .../src/session-replay-config.ts | 12 ++++++++++++ .../src/session-replay.ts | 3 ++- 9 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/plugin-session-replay-react-native/README.md b/packages/plugin-session-replay-react-native/README.md index 319db9b2a..01c9515f3 100644 --- a/packages/plugin-session-replay-react-native/README.md +++ b/packages/plugin-session-replay-react-native/README.md @@ -20,7 +20,7 @@ const config: SessionReplayConfig = { enableRemoteConfig: true, // default true sampleRate: 1, // default 0 logLevel: LogLevel.Warn, // default LogLevel.Warn - maskLevel: MaskLevel.Medium, // default MaskLevel.Medium + privacyConfig: { maskLevel: MaskLevel.Medium }, // default MaskLevel.Medium }; await init('YOUR_API_KEY').promise; await add(new SessionReplayPlugin(config)).promise; @@ -29,7 +29,7 @@ await add(new SessionReplayPlugin(config)).promise; ## Mask levels -Control how aggressively Session Replay masks sensitive content via the `maskLevel` config option: +Control how aggressively Session Replay masks sensitive content via the `privacyConfig.maskLevel` config option: | Value | What gets masked | |---|---| @@ -41,7 +41,7 @@ Control how aggressively Session Replay masks sensitive content via the `maskLev import { SessionReplayPlugin, MaskLevel } from '@amplitude/plugin-session-replay-react-native'; const config: SessionReplayConfig = { - maskLevel: MaskLevel.Conservative, // mask all text and inputs + privacyConfig: { maskLevel: MaskLevel.Conservative }, // mask all text and inputs }; ``` diff --git a/packages/plugin-session-replay-react-native/example/App.tsx b/packages/plugin-session-replay-react-native/example/App.tsx index 9316c7242..7a00c03bb 100644 --- a/packages/plugin-session-replay-react-native/example/App.tsx +++ b/packages/plugin-session-replay-react-native/example/App.tsx @@ -286,7 +286,7 @@ function App(): React.JSX.Element { enableRemoteConfig: false, sampleRate: 1, logLevel: LogLevel.Debug, - maskLevel: MaskLevel.Medium, + privacyConfig: { maskLevel: MaskLevel.Medium }, }; (async () => { await init('YOUR-API-KEY', 'example_user_id', { diff --git a/packages/plugin-session-replay-react-native/src/index.tsx b/packages/plugin-session-replay-react-native/src/index.tsx index c61a5da52..c8ff49096 100644 --- a/packages/plugin-session-replay-react-native/src/index.tsx +++ b/packages/plugin-session-replay-react-native/src/index.tsx @@ -2,4 +2,4 @@ export { SessionReplayPlugin } from './session-replay'; export { AmpMaskView } from './app-mask-view'; -export { SessionReplayConfig, MaskLevel } from './session-replay-config'; +export { SessionReplayConfig, MaskLevel, PrivacyConfig } 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 adfa16311..61de787d5 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 @@ -18,12 +18,16 @@ export enum MaskLevel { Conservative = 'conservative', } +export interface PrivacyConfig { + maskLevel?: MaskLevel; +} + export interface SessionReplayConfig { sampleRate?: number; enableRemoteConfig?: boolean; logLevel?: LogLevel; autoStart?: boolean; - maskLevel?: MaskLevel; + privacyConfig?: PrivacyConfig; } export const getDefaultConfig: () => SessionReplayConfig = () => { @@ -32,6 +36,6 @@ export const getDefaultConfig: () => SessionReplayConfig = () => { enableRemoteConfig: true, logLevel: LogLevel.Warn, autoStart: true, - maskLevel: MaskLevel.Medium, + privacyConfig: { maskLevel: MaskLevel.Medium }, }; }; 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 22c59995d..e6a8634cb 100644 --- a/packages/plugin-session-replay-react-native/src/session-replay.ts +++ b/packages/plugin-session-replay-react-native/src/session-replay.ts @@ -41,7 +41,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin { expect(config.autoStart).toBe(true); }); - it('should have maskLevel default to Medium', () => { + it('should have privacyConfig.maskLevel default to Medium', () => { const config = getDefaultConfig(); - expect(config.maskLevel).toBe(MaskLevel.Medium); + expect(config.privacyConfig?.maskLevel).toBe(MaskLevel.Medium); }); }); @@ -52,7 +52,7 @@ describe('SessionReplayPlugin.setup forwards maskLevel to native', () => { const setupArgs = () => nativeSetupMock.mock.calls[0]; it('forwards Conservative as the 9th positional argument', async () => { - const plugin = new SessionReplayPlugin({ maskLevel: MaskLevel.Conservative }); + 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); @@ -60,14 +60,14 @@ describe('SessionReplayPlugin.setup forwards maskLevel to native', () => { }); it('forwards Light as the 9th positional argument', async () => { - const plugin = new SessionReplayPlugin({ maskLevel: MaskLevel.Light }); + 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({ maskLevel: MaskLevel.Medium }); + 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'); @@ -85,7 +85,7 @@ describe('SessionReplayPlugin.setup forwards maskLevel to native', () => { enableRemoteConfig: false, logLevel: LogLevel.Debug, autoStart: false, - maskLevel: MaskLevel.Conservative, + privacyConfig: { maskLevel: MaskLevel.Conservative }, }); await plugin.setup(baseConfig as never, {} as never); const args = setupArgs(); 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..179071b81 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 @@ -95,6 +106,7 @@ export const getDefaultConfig: () => Required { } function nativeConfig(config: Required): NativeSessionReplayConfig { + const resolvedMaskLevel = config.privacyConfig?.maskLevel ?? config.maskLevel; return { ...config, logLevel: config.logLevel as NativeSessionReplayConfig['logLevel'], - maskLevel: config.maskLevel.toString() as NativeSessionReplayConfig['maskLevel'], + maskLevel: resolvedMaskLevel.toString() as NativeSessionReplayConfig['maskLevel'], }; } From 584ab86c2f1382f2d17b308b1f5d30402d696a73 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 15:31:37 -0700 Subject: [PATCH 4/7] fix(session-replay-react-native): honor deprecated maskLevel when no privacyConfig `getDefaultConfig()` previously returned `privacyConfig: { maskLevel: Medium }`, so the resolution chain `config.privacyConfig?.maskLevel ?? config.maskLevel` short-circuited on the default `privacyConfig.maskLevel` and never fell through to the user-supplied deprecated `maskLevel`. As a result, passing `{ maskLevel: Conservative }` (without `privacyConfig`) silently downgraded the masking level to `Medium`. Drop both `privacyConfig` and `maskLevel` from `getDefaultConfig()` and apply the `Medium` default only at the resolution site in `nativeConfig()`. Added test coverage for: (a) deprecated `maskLevel` is forwarded when no `privacyConfig` is provided, (b) `privacyConfig.maskLevel` wins over the deprecated `maskLevel` when both are set, (c) `Medium` is used when neither is set. Co-authored-by: Cursor --- .../src/session-replay-config.ts | 7 +- .../src/session-replay.ts | 13 ++-- .../test/session-replay.test.ts | 64 +++++++++++++++++-- 3 files changed, 71 insertions(+), 13 deletions(-) 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 179071b81..846dc9271 100644 --- a/packages/session-replay-react-native/src/session-replay-config.ts +++ b/packages/session-replay-react-native/src/session-replay-config.ts @@ -98,15 +98,16 @@ export interface SessionReplayConfig { sessionId?: number; } -export const getDefaultConfig: () => Required> = () => { +export const getDefaultConfig: () => Required> & { + privacyConfig?: PrivacyConfig; + maskLevel?: MaskLevel; +} = () => { 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 752075158..e958642e3 100644 --- a/packages/session-replay-react-native/src/session-replay.ts +++ b/packages/session-replay-react-native/src/session-replay.ts @@ -1,11 +1,16 @@ // @refresh reset import { NativeSessionReplay, type NativeSessionReplayConfig } from './native-module'; -import { getDefaultConfig, SessionReplayConfig } from './session-replay-config'; +import { getDefaultConfig, MaskLevel, PrivacyConfig, SessionReplayConfig } from './session-replay-config'; import { createSessionReplayLogger } from './logger'; import { VERSION } from './version'; -let fullConfig: Required | null = null; +type ResolvedSessionReplayConfig = Required> & { + privacyConfig?: PrivacyConfig; + maskLevel?: MaskLevel; +}; + +let fullConfig: ResolvedSessionReplayConfig | null = null; let isInitialized = false; let logger = createSessionReplayLogger(); @@ -206,8 +211,8 @@ export async function stop(): Promise { await NativeSessionReplay.stop(); } -function nativeConfig(config: Required): NativeSessionReplayConfig { - const resolvedMaskLevel = config.privacyConfig?.maskLevel ?? config.maskLevel; +function nativeConfig(config: ResolvedSessionReplayConfig): NativeSessionReplayConfig { + const resolvedMaskLevel = config.privacyConfig?.maskLevel ?? config.maskLevel ?? MaskLevel.Medium; return { ...config, logLevel: config.logLevel as NativeSessionReplayConfig['logLevel'], 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..558cdce69 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,51 @@ 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' })); + }); + }); }); From 6c93e5f6bcde7511eda33896542d8b01eb2dce8a Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 15:58:55 -0700 Subject: [PATCH 5/7] refactor(session-replay-react-native): normalize deprecated maskLevel at config boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix in 584ab86c worked but lived in the wrong layer: it stripped `privacyConfig` from `getDefaultConfig()` and re-derived the `Medium` default inside `nativeConfig()`. That mixed responsibilities — the default belongs in the defaults, and `nativeConfig()` should be a pure translation from a fully resolved internal config to the native shape. Move the deprecated→`privacyConfig` translation to a `normalizeConfig()` step that runs at the input boundary in `init()`. `getDefaultConfig()` goes back to owning the `Medium` default via `privacyConfig: { maskLevel: Medium }`, and a new module-internal `SessionReplayConfigInternal = Omit` type guarantees nothing past the boundary sees the deprecated field. `nativeConfig()` drops the `?? ?? ??` chain and just reads `privacyConfig.maskLevel`, which the merge in `init()` guarantees is set. Public `SessionReplayConfig` is unchanged; the deprecated `maskLevel` field and its `@deprecated` JSDoc stay put for backwards compat. The internal type is intentionally not re-exported from `index.tsx`. Behavior is identical: the three existing precedence tests still pass without modification, and two new tests assert that a user-supplied `privacyConfig` survives the default merge and that the internal `privacyConfig` object is not leaked through to the native bridge. Co-authored-by: Cursor --- .../src/session-replay-config.ts | 17 +++++-- .../src/session-replay.ts | 47 +++++++++++++++---- .../test/session-replay.test.ts | 18 +++++++ 3 files changed, 68 insertions(+), 14 deletions(-) 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 846dc9271..1b118b324 100644 --- a/packages/session-replay-react-native/src/session-replay-config.ts +++ b/packages/session-replay-react-native/src/session-replay-config.ts @@ -98,16 +98,25 @@ export interface SessionReplayConfig { sessionId?: number; } -export const getDefaultConfig: () => Required> & { - privacyConfig?: PrivacyConfig; - maskLevel?: MaskLevel; -} = () => { +/** + * 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, 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 e958642e3..a730e5379 100644 --- a/packages/session-replay-react-native/src/session-replay.ts +++ b/packages/session-replay-react-native/src/session-replay.ts @@ -1,14 +1,32 @@ // @refresh reset import { NativeSessionReplay, type NativeSessionReplayConfig } from './native-module'; -import { getDefaultConfig, MaskLevel, PrivacyConfig, SessionReplayConfig } from './session-replay-config'; +import { + getDefaultConfig, + PrivacyConfig, + SessionReplayConfig, + SessionReplayConfigInternal, +} from './session-replay-config'; import { createSessionReplayLogger } from './logger'; import { VERSION } from './version'; -type ResolvedSessionReplayConfig = Required> & { - privacyConfig?: PrivacyConfig; - maskLevel?: MaskLevel; -}; +type ResolvedSessionReplayConfig = Required> & + Pick; + +/** + * 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`. + */ +function normalizeConfig(config: SessionReplayConfig): SessionReplayConfigInternal { + const { maskLevel, privacyConfig, ...rest } = config; + // `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()`. + const normalizedPrivacyConfig: PrivacyConfig | undefined = + privacyConfig ?? (maskLevel !== undefined ? { maskLevel } : undefined); + return normalizedPrivacyConfig === undefined ? rest : { ...rest, privacyConfig: normalizedPrivacyConfig }; +} let fullConfig: ResolvedSessionReplayConfig | null = null; let isInitialized = false; @@ -38,9 +56,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); @@ -212,11 +234,16 @@ export async function stop(): Promise { } function nativeConfig(config: ResolvedSessionReplayConfig): NativeSessionReplayConfig { - const resolvedMaskLevel = config.privacyConfig?.maskLevel ?? config.maskLevel ?? MaskLevel.Medium; + // `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: resolvedMaskLevel.toString() as NativeSessionReplayConfig['maskLevel'], + ...rest, + logLevel: rest.logLevel as NativeSessionReplayConfig['logLevel'], + 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 558cdce69..359261e53 100644 --- a/packages/session-replay-react-native/test/session-replay.test.ts +++ b/packages/session-replay-react-native/test/session-replay.test.ts @@ -114,5 +114,23 @@ describe('Session Replay Integration Tests', () => { 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() })); + }); }); }); From 00bc47dde8e776188e22a01b79147bca06266c11 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 16:40:01 -0700 Subject: [PATCH 6/7] refactor(session-replay-react-native): simplify ResolvedSessionReplayConfig and normalizeConfig - Collapse ResolvedSessionReplayConfig to Required; apiKey is already non-optional on SessionReplayConfig, so the Omit+Pick dance was equivalent and noisy. - Replace the ?? + nested-ternary chain in normalizeConfig with explicit top-to-bottom if/else branches that read like the spec text. - Add TODO(SDKRN-15) on the line that flattens privacyConfig.maskLevel to the bridge's flat maskLevel string, so the next person hits a pointer to the follow-up native-bridge migration ticket. No behavior change. 48/48 standalone tests + 8/8 plugin tests still pass; typecheck clean. Co-authored-by: Cursor --- .../src/session-replay.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/session-replay-react-native/src/session-replay.ts b/packages/session-replay-react-native/src/session-replay.ts index a730e5379..9a6774c4c 100644 --- a/packages/session-replay-react-native/src/session-replay.ts +++ b/packages/session-replay-react-native/src/session-replay.ts @@ -1,31 +1,30 @@ // @refresh reset import { NativeSessionReplay, type NativeSessionReplayConfig } from './native-module'; -import { - getDefaultConfig, - PrivacyConfig, - SessionReplayConfig, - SessionReplayConfigInternal, -} from './session-replay-config'; +import { getDefaultConfig, SessionReplayConfig, SessionReplayConfigInternal } from './session-replay-config'; import { createSessionReplayLogger } from './logger'; import { VERSION } from './version'; -type ResolvedSessionReplayConfig = Required> & - Pick; +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; - // `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()`. - const normalizedPrivacyConfig: PrivacyConfig | undefined = - privacyConfig ?? (maskLevel !== undefined ? { maskLevel } : undefined); - return normalizedPrivacyConfig === undefined ? rest : { ...rest, privacyConfig: normalizedPrivacyConfig }; + if (privacyConfig !== undefined) { + return { ...rest, privacyConfig }; + } + if (maskLevel !== undefined) { + return { ...rest, privacyConfig: { maskLevel } }; + } + return rest; } let fullConfig: ResolvedSessionReplayConfig | null = null; @@ -243,6 +242,7 @@ function nativeConfig(config: ResolvedSessionReplayConfig): NativeSessionReplayC return { ...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'], }; } From 22e05443c4d88c486fd5f0610b2fc2ba1231f5c1 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 22 May 2026 17:11:50 -0700 Subject: [PATCH 7/7] refactor(plugin-session-replay-react-native): align config shape with standalone package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the recent shape polish on the standalone `@amplitude/session-replay-react-native` package so the two share the same config-handling style. No public API changes — same exports, same field names, same `SessionReplayConfig` / `PrivacyConfig` / `MaskLevel`. - `getDefaultConfig` now returns `Required` with `privacyConfig: { maskLevel: MaskLevel.Medium }` baked into the defaults, matching the standalone's `Required<...>` return shape. - `SessionReplayConfig` field order alphabetized and full JSDoc with `@default` annotations copied from the standalone where semantics are identical. Added a class-level JSDoc explaining why `apiKey`, `deviceId`, `sessionId`, `serverZone`, and the deprecated `maskLevel` are intentionally absent from the plugin config (auto-sourced from the analytics client; no deprecated field to migrate). - Introduced `type ResolvedSessionReplayConfig = Required` alias and typed `sessionReplayConfig` with it, mirroring the standalone's `ResolvedSessionReplayConfig` pattern. Plugin does not need a `SessionReplayConfigInternal` alias because it has no deprecated field to omit. - Removed redundant `?? default` fallbacks in `setup()` now that `getDefaultConfig()` guarantees every field is set after the shallow spread. Notably, this fixes a latent bug where `sampleRate` fell back to `1` in `setup()` instead of the documented `0` default. - Destructured `privacyConfig` out of `sessionReplayConfig` and read `privacyConfig.maskLevel` directly with no `?? MaskLevel.Medium` fallback. Added the matching `// TODO(SDKRN-15): Migrate native bridge to accept the full privacyConfig object instead of a flat maskLevel string.` comment. - Aligned `index.tsx` export ordering and added `type` qualifier on `SessionReplayConfig` to match the standalone. The native bridge stays on its positional-args shape (vs the standalone's config-object bridge) — that asymmetry is a deeper refactor tracked under SDKRN-15. Co-authored-by: Cursor --- .../src/index.tsx | 3 +- .../src/session-replay-config.ts | 47 +++++++++++++++++-- .../src/session-replay.ts | 25 ++++++---- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/plugin-session-replay-react-native/src/index.tsx b/packages/plugin-session-replay-react-native/src/index.tsx index c8ff49096..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, MaskLevel, PrivacyConfig } 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 61de787d5..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 @@ -22,20 +22,57 @@ 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 e6a8634cb..55efc3f92 100644 --- a/packages/plugin-session-replay-react-native/src/session-replay.ts +++ b/packages/plugin-session-replay-react-native/src/session-replay.ts @@ -7,8 +7,9 @@ import type { EnrichmentPlugin, Event, ReactNativeClient, ReactNativeConfig } fr import { PluginSessionReplayReactNative } from './native-module'; import { VERSION } from './version'; -import { MaskLevel, SessionReplayConfig, getDefaultConfig } from './session-replay-config'; -import { LogLevel } from '@amplitude/analytics-types'; +import { SessionReplayConfig, getDefaultConfig } from './session-replay-config'; + +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.privacyConfig?.maskLevel ?? MaskLevel.Medium, + 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; }