Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/plugin-session-replay-react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,43 @@ 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';

// ...

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 `<TextInput>` fields only |
| `MaskLevel.Medium` (default) | All `<TextInput>` fields |
| `MaskLevel.Conservative` | All `<TextInput>` fields **and** all `<Text>` 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 `<AmpMaskView mask="amp-mask">` 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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -54,7 +64,8 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic
"EU" -> ServerZone.EU
else -> ServerZone.US
},
autoStart = autoStart
autoStart = autoStart,
privacyConfig = PrivacyConfig(maskLevel = mappedMaskLevel)
)
}

Expand Down Expand Up @@ -87,7 +98,7 @@ class PluginSessionReplayReactNativeModule(private val reactContext: ReactApplic
}
promise.resolve(map)
}

@ReactMethod
fun start() {
sessionReplay.start()
Expand Down
5 changes: 3 additions & 2 deletions packages/plugin-session-replay-react-native/example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +15,7 @@ class PluginSessionReplayReactNative: NSObject {
enableRemoteConfig: Bool,
logLevel: Int,
autoStart: Bool,
maskLevel: String,
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock) -> Void {
print(
Expand All @@ -28,6 +29,7 @@ class PluginSessionReplayReactNative: NSObject {
Enable Remote Config: \(enableRemoteConfig)
Log Level: \(logLevel)
Auto Start: \(autoStart)
Mask Level: \(maskLevel)
"""
)
sessionReplay = SessionReplay(apiKey:apiKey,
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
}
}
5 changes: 5 additions & 0 deletions packages/plugin-session-replay-react-native/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module.exports = {
modulePathIgnorePatterns: [
"<rootDir>/lib/"
],
testPathIgnorePatterns: [
...(baseConfig.testPathIgnorePatterns || []),
'<rootDir>/example/',
],
moduleFileExtensions: ['tsx', 'ts', 'js', 'jsx', 'json'],
transformIgnorePatterns: [
'node_modules/(?!(.pnpm|@react-native|react-native|@segment)/)',
],
Expand Down
3 changes: 1 addition & 2 deletions packages/plugin-session-replay-react-native/src/index.tsx
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you also need to apply those changes to session-replay-react-native package?

Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<SessionReplayConfig> = () => {
return {
sampleRate: 0,
autoStart: true,
enableRemoteConfig: true,
logLevel: LogLevel.Warn,
autoStart: true,
privacyConfig: { maskLevel: MaskLevel.Medium },
sampleRate: 0,
};
};
export { LogLevel };
22 changes: 16 additions & 6 deletions packages/plugin-session-replay-react-native/src/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionReplayConfig>;

export class SessionReplayPlugin implements EnrichmentPlugin<ReactNativeClient, ReactNativeConfig> {
name = '@amplitude/plugin-session-replay-react-native';
Expand All @@ -19,7 +20,7 @@ export class SessionReplayPlugin implements EnrichmentPlugin<ReactNativeClient,
config: ReactNativeConfig;
isInitialized = false;

sessionReplayConfig: SessionReplayConfig;
sessionReplayConfig: ResolvedSessionReplayConfig;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shallow merge lets privacyConfig.maskLevel become undefined

High Severity

The constructor's shallow ...config spread replaces the default privacyConfig: { maskLevel: Medium } wholesale when a user supplies { privacyConfig: {} }. Because maskLevel is optional in the PrivacyConfig interface, privacyConfig.maskLevel becomes undefined. This undefined is then passed as a positional argument to the native bridge where Kotlin expects a non-nullable String and Swift expects a non-optional String, causing a native crash. The same pattern exists in the standalone package's nativeConfig() function, where privacyConfig.maskLevel is cast via as to hide the undefined from TypeScript.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 22e0544. Configure here.


constructor(config: SessionReplayConfig = {}) {
this.sessionReplayConfig = {
Expand All @@ -32,15 +33,24 @@ export class SessionReplayPlugin implements EnrichmentPlugin<ReactNativeClient,
async setup(config: ReactNativeConfig, _: ReactNativeClient): Promise<void> {
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;
}
Expand Down
Loading
Loading