From 13b3c14a871bac62d49c82f9607c1fc08d16d150 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 18 Nov 2024 11:51:30 +0100 Subject: [PATCH 1/3] SR: Fine Grained Masking privacy configuration --- .../DdSessionReplayImplementation.kt | 47 +++++- .../sessionreplay/DdSessionReplay.kt | 16 +- .../sessionreplay/DdSessionReplay.kt | 12 +- .../ios/Sources/DdSessionReplay.mm | 16 +- .../DdSessionReplayImplementation.swift | 53 ++++-- .../src/SessionReplay.ts | 155 +++++++++++++++--- .../src/nativeModulesTypes.ts | 19 ++- .../src/specs/NativeDdSessionReplay.ts | 12 +- 8 files changed, 281 insertions(+), 49 deletions(-) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt index f46c34527..221229f73 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -28,22 +28,28 @@ class DdSessionReplayImplementation( /** * Enable session replay and start recording session. * @param replaySampleRate The sample rate applied for session replay. - * @param defaultPrivacyLevel The privacy level used for replay. * @param customEndpoint Custom server url for sending replay data. + * @param imagePrivacyLevel Defines the way images should be masked. + * @param touchPrivacyLevel Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel Defines the way text and input should be masked. * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. */ fun enable( replaySampleRate: Double, - defaultPrivacyLevel: String, customEndpoint: String, + imagePrivacyLevel: String, + touchPrivacyLevel: String, + textAndInputPrivacyLevel: String, startRecordingImmediately: Boolean, promise: Promise ) { val sdkCore = DatadogSDKWrapperStorage.getSdkCore() as FeatureSdkCore val logger = sdkCore.internalLogger val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) - .configurePrivacy(defaultPrivacyLevel) .startRecordingImmediately(startRecordingImmediately) + .setImagePrivacy(convertImagePrivacyLevel(imagePrivacyLevel)) + .setTouchPrivacy(convertTouchPrivacyLevel(touchPrivacyLevel)) + .setTextAndInputPrivacy(convertTextAndInputPrivacyLevel(textAndInputPrivacyLevel)) .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger)) if (customEndpoint != "") { @@ -102,5 +108,40 @@ class DdSessionReplayImplementation( companion object { internal const val NAME = "DdSessionReplay" + + internal fun convertImagePrivacyLevel(imagePrivacyLevel: String): ImagePrivacy { + return when (imagePrivacyLevel) { + "MASK_NON_BUNDLED_ONLY" -> ImagePrivacy.MASK_LARGE_ONLY + "MASK_ALL" -> ImagePrivacy.MASK_ALL + "MASK_NONE" -> ImagePrivacy.MASK_NONE + else -> { + // TODO: Log wrong usage / mapping. + ImagePrivacy.MASK_ALL + } + } + } + + internal fun convertTouchPrivacyLevel(touchPrivacyLevel: String): TouchPrivacy { + return when (touchPrivacyLevel) { + "SHOW" -> TouchPrivacy.SHOW + "HIDE" -> TouchPrivacy.HIDE + else -> { + // TODO: Log wrong usage / mapping. + TouchPrivacy.HIDE + } + } + } + + internal fun convertTextAndInputPrivacyLevel(textAndInputPrivacyLevel: String): TextAndInputPrivacy { + return when (textAndInputPrivacyLevel) { + "MASK_SENSITIVE_INPUTS" -> TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + "MASK_ALL_INPUTS" -> TextAndInputPrivacy.MASK_ALL_INPUTS + "MASK_ALL" -> TextAndInputPrivacy.MASK_ALL + else -> { + // TODO: Log wrong usage / mapping + TextAndInputPrivacy.MASK_ALL + } + } + } } } diff --git a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt index f9f72aa62..1dce9ab2b 100644 --- a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt +++ b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt @@ -26,14 +26,26 @@ class DdSessionReplay( * @param replaySampleRate The sample rate applied for session replay. * @param defaultPrivacyLevel The privacy level used for replay. * @param customEndpoint Custom server url for sending replay data. + * @param imagePrivacyLevel Defines the way images should be masked. + * @param touchPrivacyLevel Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel Defines the way text and input should be masked. */ @ReactMethod override fun enable( replaySampleRate: Double, - defaultPrivacyLevel: String, customEndpoint: String, + imagePrivacyLevel: String, + touchPrivacyLevel: String, + textAndInputPrivacyLevel: String, promise: Promise ) { - implementation.enable(replaySampleRate, defaultPrivacyLevel, customEndpoint, promise) + implementation.enable( + replaySampleRate, + customEndpoint, + imagePrivacyLevel, + touchPrivacyLevel, + textAndInputPrivacyLevel, + promise + ) } } diff --git a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt index 0cfdbc138..16ce3d82f 100644 --- a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt +++ b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt @@ -25,22 +25,28 @@ class DdSessionReplay( /** * Enable session replay and start recording session. * @param replaySampleRate The sample rate applied for session replay. - * @param defaultPrivacyLevel The privacy level used for replay. * @param customEndpoint Custom server url for sending replay data. + * @param imagePrivacyLevel Defines the way images should be masked. + * @param touchPrivacyLevel Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel Defines the way text and input should be masked. * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. */ @ReactMethod fun enable( replaySampleRate: Double, - defaultPrivacyLevel: String, customEndpoint: String, + imagePrivacyLevel: String, + touchPrivacyLevel: String, + textAndInputPrivacyLevel: String, startRecordingImmediately: Boolean, promise: Promise ) { implementation.enable( replaySampleRate, - defaultPrivacyLevel, customEndpoint, + imagePrivacyLevel, + touchPrivacyLevel, + textAndInputPrivacyLevel, startRecordingImmediately, promise ) diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm index 1b33e79f1..c750f0979 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm @@ -18,15 +18,19 @@ @implementation DdSessionReplay RCT_EXPORT_MODULE() RCT_REMAP_METHOD(enable, withEnableReplaySampleRate:(double)replaySampleRate - withDefaultPrivacyLevel:(NSString*)defaultPrivacyLevel withCustomEndpoint:(NSString*)customEndpoint + withImagePrivacyLevel:(NSString*)imagePrivacyLevel + withTouchPrivacyLevel:(NSString*)touchPrivacyLevel + withTextAndInputPrivacyLevel:(NSString*)textAndInputPrivacyLevel withStartRecordingImmediately:(BOOL)startRecordingImmediately withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) { [self enable:replaySampleRate - defaultPrivacyLevel:defaultPrivacyLevel customEndpoint:customEndpoint + imagePrivacyLevel:imagePrivacyLevel + touchPrivacyLevel:touchPrivacyLevel + textAndInputPrivacyLevel:textAndInputPrivacyLevel startRecordingImmediately:startRecordingImmediately resolve:resolve reject:reject]; @@ -64,14 +68,18 @@ + (BOOL)requiresMainQueueSetup { } - (void)enable:(double)replaySampleRate - defaultPrivacyLevel:(NSString *)defaultPrivacyLevel customEndpoint:(NSString*)customEndpoint + imagePrivacyLevel:(NSString *)imagePrivacyLevel + touchPrivacyLevel:(NSString *)touchPrivacyLevel + textAndInputPrivacyLevel:(NSString *)textAndInputPrivacyLevel startRecordingImmediately:(BOOL)startRecordingImmediately resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.ddSessionReplayImplementation enableWithReplaySampleRate:replaySampleRate - defaultPrivacyLevel:defaultPrivacyLevel customEndpoint:customEndpoint + imagePrivacyLevel:imagePrivacyLevel + touchPrivacyLevel:touchPrivacyLevel + textAndInputPrivacyLevel:textAndInputPrivacyLevel startRecordingImmediately:startRecordingImmediately resolve:resolve reject:reject]; diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index 6166d8866..04ba6f9b0 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -32,8 +32,10 @@ public class DdSessionReplayImplementation: NSObject { @objc public func enable( replaySampleRate: Double, - defaultPrivacyLevel: String, customEndpoint: String, + imagePrivacyLevel: String, + touchPrivacyLevel: String, + textAndInputPrivacyLevel: String, startRecordingImmediately: Bool, resolve:RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock @@ -44,7 +46,9 @@ public class DdSessionReplayImplementation: NSObject { } var sessionReplayConfiguration = SessionReplay.Configuration( replaySampleRate: Float(replaySampleRate), - defaultPrivacyLevel: buildPrivacyLevel(privacyLevel: defaultPrivacyLevel as NSString), + imagePrivacyLevel: convertImagePrivacy(imagePrivacyLevel), + touchPrivacyLevel: convertTouchPrivacy(touchPrivacyLevel), + textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel), startRecordingImmediately: startRecordingImmediately, customEndpoint: customEndpointURL ) @@ -85,16 +89,43 @@ public class DdSessionReplayImplementation: NSObject { resolve(nil) } - func buildPrivacyLevel(privacyLevel: NSString) -> SessionReplayPrivacyLevel { - switch privacyLevel.lowercased { - case "mask": - return .mask - case "mask_user_input": - return .maskUserInput - case "allow": - return .allow + func convertImagePrivacy(_ imagePrivacy: NSString) -> ImagePrivacyLevel { + switch imagePrivacy { + case "MASK_NON_BUNDLED_ONLY": + return .maskNonBundledOnly + case "MASK_ALL": + return .maskAll + case "MASK_NONE": + return .maskNone default: - return .mask + // TODO: Log wrong usage / mapping + return .maskAll + } + } + + func convertTouchPrivacy(_ touchPrivacy: NSString) -> TouchPrivacyLevel { + switch touchPrivacy { + case "SHOW": + return .show + case "HIDE": + return .hide + default: + // TODO: Log wrong usage / mapping + return .hide + } + } + + func convertTextAndInputPrivacy(_ textAndInputPrivacy: NSString) -> TextAndInputPrivacyLevel { + switch textAndInputPrivacy { + case "MASK_SENSITIVE_INPUTS": + return .maskSensitiveInputs + case "MASK_ALL_INPUTS": + return .maskAllInputs + case "MASK_ALL": + return .maskAll + default: + // TODO: Log wrong usage / mapping + return .maskAll } } } diff --git a/packages/react-native-session-replay/src/SessionReplay.ts b/packages/react-native-session-replay/src/SessionReplay.ts index cfdf8de96..7875f63af 100644 --- a/packages/react-native-session-replay/src/SessionReplay.ts +++ b/packages/react-native-session-replay/src/SessionReplay.ts @@ -12,6 +12,49 @@ export enum SessionReplayPrivacy { MASK_USER_INPUT = 'MASK_USER_INPUT' } +export enum ImagePrivacyLevel { + /** + * Only images that are bundled within the application will be recorded. + * + * On Android, all images larger than 100x100 dp will be masked, as we consider them non-bundled images. + */ + MASK_NON_BUNDLED_ONLY = 'MASK_NON_BUNDLED_ONLY', + /** + * No images will be recorded. + */ + MASK_ALL = 'MASK_ALL', + /** + * All images will be recorded, including the ones downloaded from the Internet or generated during the app runtime. + */ + MASK_NONE = 'MASK_NONE' +} + +export enum TouchPrivacyLevel { + /** + * Show all user touches. + */ + SHOW = 'SHOW', + /** + * Hide all user touches. + */ + HIDE = 'HIDE' +} + +export enum TextAndInputPrivacyLevel { + /** + * Show all texts except sensitive inputs (e.g password fields). + */ + MASK_SENSITIVE_INPUTS = 'MASK_SENSITIVE_INPUTS', + /** + * Mask all input fields (e.g text fields, switches, checkboxes). + */ + MASK_ALL_INPUTS = 'MASK_ALL_INPUTS', + /** + * Mask all texts and inputs (e.g labels). + */ + MASK_ALL = 'MASK_ALL' +} + /** * The Session Replay configuration object. */ @@ -24,16 +67,27 @@ export interface SessionReplayConfiguration { * Default value is `20`. */ replaySampleRate?: number; + /** - * Defines the way sensitive content (e.g. text) should be masked. - * - * Default `SessionReplayPrivacy.MASK`. + * Defines the way images should be masked (Default: `MASK_ALL`) */ - defaultPrivacyLevel?: SessionReplayPrivacy; + imagePrivacyLevel?: ImagePrivacyLevel; + + /** + * Defines the way user touches (e.g tap) should be masked (Default: `HIDE`) + */ + touchPrivacyLevel?: TouchPrivacyLevel; + + /** + * Defines the way text and input (e.g text fields, checkboxes) should be masked (Default: `MASK_ALL`) + */ + textAndInputPrivacyLevel?: TextAndInputPrivacyLevel; + /** * Custom server url for sending replay data. */ customEndpoint?: string; + /** * Whether the recording should start automatically when the feature is enabled. * When `true`, the recording starts automatically. @@ -41,12 +95,41 @@ export interface SessionReplayConfiguration { * Default: `true`. */ startRecordingImmediately?: boolean; + + /** + * Defines the way sensitive content (e.g. text) should be masked. + * + * Default `SessionReplayPrivacy.MASK`. + * @deprecated Use {@link imagePrivacyLevel}, {@link touchPrivacyLevel} and {@link textAndInputPrivacyLevel} instead. + * Note: setting this property (`defaultPrivacyLevel`) will override the individual privacy levels. + */ + defaultPrivacyLevel?: SessionReplayPrivacy; } -const DEFAULTS = { +type InternalBaseSessionReplayConfiguration = { + replaySampleRate: number; + customEndpoint: string; + startRecordingImmediately: boolean; +}; + +type InternalPrivacySessionReplayConfiguration = { + imagePrivacyLevel: ImagePrivacyLevel; + touchPrivacyLevel: TouchPrivacyLevel; + textAndInputPrivacyLevel: TextAndInputPrivacyLevel; +}; + +type InternalSessionReplayConfiguration = InternalBaseSessionReplayConfiguration & + InternalPrivacySessionReplayConfiguration; + +const DEFAULTS: InternalSessionReplayConfiguration & { + defaultPrivacyLevel: SessionReplayPrivacy; +} = { replaySampleRate: 0, defaultPrivacyLevel: SessionReplayPrivacy.MASK, customEndpoint: '', + imagePrivacyLevel: ImagePrivacyLevel.MASK_ALL, + touchPrivacyLevel: TouchPrivacyLevel.HIDE, + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_ALL, startRecordingImmediately: true }; @@ -57,30 +140,21 @@ export class SessionReplayWrapper { private buildConfiguration = ( configuration?: SessionReplayConfiguration - ): { - replaySampleRate: number; - defaultPrivacyLevel: SessionReplayPrivacy; - customEndpoint: string; - startRecordingImmediately: boolean; - } => { + ): InternalSessionReplayConfiguration => { if (!configuration) { return DEFAULTS; } const { replaySampleRate, - defaultPrivacyLevel, customEndpoint, startRecordingImmediately } = configuration; - return { + + const baseConfig: InternalBaseSessionReplayConfiguration = { replaySampleRate: replaySampleRate !== undefined ? replaySampleRate : DEFAULTS.replaySampleRate, - defaultPrivacyLevel: - defaultPrivacyLevel !== undefined - ? defaultPrivacyLevel - : DEFAULTS.defaultPrivacyLevel, customEndpoint: customEndpoint !== undefined ? customEndpoint @@ -90,6 +164,45 @@ export class SessionReplayWrapper { ? startRecordingImmediately : DEFAULTS.startRecordingImmediately }; + + const privacyConfig: InternalPrivacySessionReplayConfiguration = { + imagePrivacyLevel: + configuration.imagePrivacyLevel ?? DEFAULTS.imagePrivacyLevel, + touchPrivacyLevel: + configuration.touchPrivacyLevel ?? DEFAULTS.touchPrivacyLevel, + textAndInputPrivacyLevel: + configuration.textAndInputPrivacyLevel ?? + DEFAULTS.textAndInputPrivacyLevel + }; + + // Legacy Default Privacy Level property handling + if (configuration.defaultPrivacyLevel) { + switch (configuration.defaultPrivacyLevel) { + case SessionReplayPrivacy.MASK: + privacyConfig.imagePrivacyLevel = + ImagePrivacyLevel.MASK_ALL; + privacyConfig.touchPrivacyLevel = TouchPrivacyLevel.HIDE; + privacyConfig.textAndInputPrivacyLevel = + TextAndInputPrivacyLevel.MASK_ALL; + break; + case SessionReplayPrivacy.MASK_USER_INPUT: + privacyConfig.imagePrivacyLevel = + ImagePrivacyLevel.MASK_NONE; + privacyConfig.touchPrivacyLevel = TouchPrivacyLevel.HIDE; + privacyConfig.textAndInputPrivacyLevel = + TextAndInputPrivacyLevel.MASK_ALL_INPUTS; + break; + case SessionReplayPrivacy.ALLOW: + privacyConfig.imagePrivacyLevel = + ImagePrivacyLevel.MASK_NONE; + privacyConfig.touchPrivacyLevel = TouchPrivacyLevel.SHOW; + privacyConfig.textAndInputPrivacyLevel = + TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS; + break; + } + } + + return { ...baseConfig, ...privacyConfig }; }; /** @@ -99,15 +212,19 @@ export class SessionReplayWrapper { enable = (configuration?: SessionReplayConfiguration): Promise => { const { replaySampleRate, - defaultPrivacyLevel, customEndpoint, + imagePrivacyLevel, + touchPrivacyLevel, + textAndInputPrivacyLevel, startRecordingImmediately } = this.buildConfiguration(configuration); return this.nativeSessionReplay.enable( replaySampleRate, - defaultPrivacyLevel, customEndpoint, + imagePrivacyLevel, + touchPrivacyLevel, + textAndInputPrivacyLevel, startRecordingImmediately ); }; diff --git a/packages/react-native-session-replay/src/nativeModulesTypes.ts b/packages/react-native-session-replay/src/nativeModulesTypes.ts index 00f90202e..86e7d4668 100644 --- a/packages/react-native-session-replay/src/nativeModulesTypes.ts +++ b/packages/react-native-session-replay/src/nativeModulesTypes.ts @@ -11,7 +11,12 @@ import type { Spec as NativeDdSessionReplay } from './specs/NativeDdSessionRepla * As we cannot use enums or classes in the specs, we override methods using them here. */ -type PrivacyLevel = 'MASK' | 'MASK_USER_INPUT' | 'ALLOW'; +type ImagePrivacyLevel = 'MASK_NON_BUNDLED_ONLY' | 'MASK_ALL' | 'MASK_NONE'; +type TouchPrivacyLevel = 'SHOW' | 'HIDE'; +type TextAndInputPrivacyLevel = + | 'MASK_SENSITIVE_INPUTS' + | 'MASK_ALL_INPUTS' + | 'MASK_ALL'; /** * The entry point to use Datadog's Session Replay feature. @@ -20,14 +25,20 @@ export interface NativeSessionReplayType extends NativeDdSessionReplay { /** * Enable session replay and start recording session. * @param replaySampleRate: The sample rate applied for session replay. - * @param defaultPrivacyLevel: The privacy level used for replay. * @param customEndpoint: Custom server url for sending replay data. - * @param startRecordingImmediately: Whether the recording should start automatically when the feature is enabled. When `true`, the recording starts automatically; when `false` it doesn't, and the recording will need to be started manually. Default: `true`. + * @param imagePrivacyLevel: Defines the way images should be masked. + * @param touchPrivacyLevel: Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel: Defines the way text and input should be masked. + * @param startRecordingImmediately: Whether the recording should start automatically when the feature is enabled. + * When `true`, the recording starts automatically; when `false` it doesn't, and the recording will need + * to be started manually. Default: `true`. */ enable( replaySampleRate: number, - defaultPrivacyLevel: PrivacyLevel, customEndpoint: string, + imagePrivacyLevel: ImagePrivacyLevel, + touchPrivacyLevel: TouchPrivacyLevel, + textAndInputPrivacyLevel: TextAndInputPrivacyLevel, startRecordingImmediately: boolean ): Promise; diff --git a/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts b/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts index 272ebcf77..61a73eed1 100644 --- a/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts +++ b/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts @@ -17,14 +17,20 @@ export interface Spec extends TurboModule { /** * Enable session replay and start recording session. * @param replaySampleRate: The sample rate applied for session replay. - * @param defaultPrivacyLevel: The privacy level used for replay. * @param customEndpoint: Custom server url for sending replay data. - * @param startRecordingImmediately: Whether the recording should start automatically when the feature is enabled. When `true`, the recording starts automatically; when `false` it doesn't, and the recording will need to be started manually. Default: `true`. + * @param imagePrivacyLevel: Defines the way images should be masked. + * @param touchPrivacyLevel: Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel: Defines the way text and input should be masked. + * @param startRecordingImmediately: Whether the recording should start automatically when the feature is enabled. + * When `true`, the recording starts automatically; when `false` it doesn't, + * and the recording will need to be started manually. Default: `true`. */ enable( replaySampleRate: number, - defaultPrivacyLevel: string, customEndpoint: string, + imagePrivacyLevel: string, + touchPrivacyLevel: string, + textAndInputPrivacyLevel: string, startRecordingImmediately: boolean ): Promise; From 5fb15a2a3daf693ec358a41959d69284fc59ce9c Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 18 Nov 2024 14:46:30 +0100 Subject: [PATCH 2/3] Added tests for SR FGM configuration --- .../DdSessionReplayImplementation.kt | 76 +------- .../SessionReplayPrivacySettings.kt | 77 ++++++++ .../sessionreplay/DdSessionReplay.kt | 8 +- .../sessionreplay/DdSessionReplay.kt | 8 +- .../DdSessionReplayImplementationTest.kt | 104 +++++------ .../DdSessionReplayImplementation.swift | 8 +- .../ios/Tests/DdSessionReplayTests.swift | 176 +++++++----------- .../src/__tests__/SessionReplay.test.ts | 100 ++++++++-- 8 files changed, 297 insertions(+), 260 deletions(-) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt index 221229f73..354a03a24 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -29,17 +29,14 @@ class DdSessionReplayImplementation( * Enable session replay and start recording session. * @param replaySampleRate The sample rate applied for session replay. * @param customEndpoint Custom server url for sending replay data. - * @param imagePrivacyLevel Defines the way images should be masked. - * @param touchPrivacyLevel Defines the way user touches should be masked. - * @param textAndInputPrivacyLevel Defines the way text and input should be masked. + * @param privacySettings Defines the way visual elements should be masked. + * @param customEndpoint Custom server url for sending replay data. * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. */ fun enable( replaySampleRate: Double, customEndpoint: String, - imagePrivacyLevel: String, - touchPrivacyLevel: String, - textAndInputPrivacyLevel: String, + privacySettings: SessionReplayPrivacySettings, startRecordingImmediately: Boolean, promise: Promise ) { @@ -47,9 +44,9 @@ class DdSessionReplayImplementation( val logger = sdkCore.internalLogger val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) .startRecordingImmediately(startRecordingImmediately) - .setImagePrivacy(convertImagePrivacyLevel(imagePrivacyLevel)) - .setTouchPrivacy(convertTouchPrivacyLevel(touchPrivacyLevel)) - .setTextAndInputPrivacy(convertTextAndInputPrivacyLevel(textAndInputPrivacyLevel)) + .setImagePrivacy(privacySettings.imagePrivacyLevel) + .setTouchPrivacy(privacySettings.touchPrivacyLevel) + .setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel) .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger)) if (customEndpoint != "") { @@ -80,68 +77,7 @@ class DdSessionReplayImplementation( promise.resolve(null) } - @Deprecated("Privacy should be set with separate properties mapped to " + - "`setImagePrivacy`, `setTouchPrivacy`, `setTextAndInputPrivacy`, but they are" + - " currently unavailable.") - private fun SessionReplayConfiguration.Builder.configurePrivacy( - defaultPrivacyLevel: String - ): SessionReplayConfiguration.Builder { - when (defaultPrivacyLevel.lowercase(Locale.US)) { - "mask" -> { - this.setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL) - this.setImagePrivacy(ImagePrivacy.MASK_ALL) - this.setTouchPrivacy(TouchPrivacy.HIDE) - } - "mask_user_input" -> { - this.setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL_INPUTS) - this.setImagePrivacy(ImagePrivacy.MASK_NONE) - this.setTouchPrivacy(TouchPrivacy.HIDE) - } - "allow" -> { - this.setTextAndInputPrivacy(TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) - this.setImagePrivacy(ImagePrivacy.MASK_NONE) - this.setTouchPrivacy(TouchPrivacy.SHOW) - } - } - return this - } - companion object { internal const val NAME = "DdSessionReplay" - - internal fun convertImagePrivacyLevel(imagePrivacyLevel: String): ImagePrivacy { - return when (imagePrivacyLevel) { - "MASK_NON_BUNDLED_ONLY" -> ImagePrivacy.MASK_LARGE_ONLY - "MASK_ALL" -> ImagePrivacy.MASK_ALL - "MASK_NONE" -> ImagePrivacy.MASK_NONE - else -> { - // TODO: Log wrong usage / mapping. - ImagePrivacy.MASK_ALL - } - } - } - - internal fun convertTouchPrivacyLevel(touchPrivacyLevel: String): TouchPrivacy { - return when (touchPrivacyLevel) { - "SHOW" -> TouchPrivacy.SHOW - "HIDE" -> TouchPrivacy.HIDE - else -> { - // TODO: Log wrong usage / mapping. - TouchPrivacy.HIDE - } - } - } - - internal fun convertTextAndInputPrivacyLevel(textAndInputPrivacyLevel: String): TextAndInputPrivacy { - return when (textAndInputPrivacyLevel) { - "MASK_SENSITIVE_INPUTS" -> TextAndInputPrivacy.MASK_SENSITIVE_INPUTS - "MASK_ALL_INPUTS" -> TextAndInputPrivacy.MASK_ALL_INPUTS - "MASK_ALL" -> TextAndInputPrivacy.MASK_ALL - else -> { - // TODO: Log wrong usage / mapping - TextAndInputPrivacy.MASK_ALL - } - } - } } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt new file mode 100644 index 000000000..ef4975a05 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt @@ -0,0 +1,77 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.reactnative.sessionreplay + +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy + +/** + * A utility class to store Session Replay privacy settings, and convert them from string to + * enum values. + * + * @param imagePrivacyLevel Defines the way images should be masked. + * @param touchPrivacyLevel Defines the way user touches should be masked. + * @param textAndInputPrivacyLevel Defines the way text and input should be masked. + */ +class SessionReplayPrivacySettings( + imagePrivacyLevel: String, + touchPrivacyLevel: String, + textAndInputPrivacyLevel: String +){ + /** + * Defines the way images should be masked. + */ + val imagePrivacyLevel = getImagePrivacy(imagePrivacyLevel) + + /** + * Defines the way user touches should be masked. + */ + val touchPrivacyLevel = getTouchPrivacy(touchPrivacyLevel) + + /** + * Defines the way text and input should be masked. + */ + val textAndInputPrivacyLevel = getTextAndInputPrivacy(textAndInputPrivacyLevel) + + companion object { + internal fun getImagePrivacy(imagePrivacyLevel: String): ImagePrivacy { + return when (imagePrivacyLevel) { + "MASK_NON_BUNDLED_ONLY" -> ImagePrivacy.MASK_LARGE_ONLY + "MASK_ALL" -> ImagePrivacy.MASK_ALL + "MASK_NONE" -> ImagePrivacy.MASK_NONE + else -> { + // TODO: Log wrong usage / mapping. + ImagePrivacy.MASK_ALL + } + } + } + + internal fun getTouchPrivacy(touchPrivacyLevel: String): TouchPrivacy { + return when (touchPrivacyLevel) { + "SHOW" -> TouchPrivacy.SHOW + "HIDE" -> TouchPrivacy.HIDE + else -> { + // TODO: Log wrong usage / mapping. + TouchPrivacy.HIDE + } + } + } + + internal fun getTextAndInputPrivacy(textAndInputPrivacyLevel: String): TextAndInputPrivacy { + return when (textAndInputPrivacyLevel) { + "MASK_SENSITIVE_INPUTS" -> TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + "MASK_ALL_INPUTS" -> TextAndInputPrivacy.MASK_ALL_INPUTS + "MASK_ALL" -> TextAndInputPrivacy.MASK_ALL + else -> { + // TODO: Log wrong usage / mapping + TextAndInputPrivacy.MASK_ALL + } + } + } + } +} \ No newline at end of file diff --git a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt index 1dce9ab2b..a3bf919ee 100644 --- a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt +++ b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt @@ -42,9 +42,11 @@ class DdSessionReplay( implementation.enable( replaySampleRate, customEndpoint, - imagePrivacyLevel, - touchPrivacyLevel, - textAndInputPrivacyLevel, + SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacyLevel, + touchPrivacyLevel = touchPrivacyLevel, + textAndInputPrivacyLevel = textAndInputPrivacyLevel + ), promise ) } diff --git a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt index 16ce3d82f..e8269a6e2 100644 --- a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt +++ b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplay.kt @@ -44,9 +44,11 @@ class DdSessionReplay( implementation.enable( replaySampleRate, customEndpoint, - imagePrivacyLevel, - touchPrivacyLevel, - textAndInputPrivacyLevel, + SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacyLevel, + touchPrivacyLevel = touchPrivacyLevel, + textAndInputPrivacyLevel = textAndInputPrivacyLevel + ), startRecordingImmediately, promise ) diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt index 1be9191dc..3d6a2c6d5 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt @@ -20,7 +20,6 @@ import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension -import java.util.Locale import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -57,6 +56,23 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockUiManagerModule: UIManagerModule + private val imagePrivacyMap = mapOf( + "MASK_ALL" to ImagePrivacy.MASK_ALL, + "MASK_NON_BUNDLED_ONLY" to ImagePrivacy.MASK_LARGE_ONLY, + "MASK_NONE" to ImagePrivacy.MASK_NONE + ) + + private val touchPrivacyMap = mapOf( + "SHOW" to TouchPrivacy.SHOW, + "HIDE" to TouchPrivacy.HIDE + ) + + private val inputPrivacyMap = mapOf( + "MASK_ALL" to TextAndInputPrivacy.MASK_ALL, + "MASK_ALL_INPUTS" to TextAndInputPrivacy.MASK_ALL_INPUTS, + "MASK_SENSITIVE_INPUTS" to TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + ) + @BeforeEach fun `set up`() { whenever(mockReactContext.getNativeModule(any>())) @@ -71,51 +87,31 @@ internal class DdSessionReplayImplementationTest { } @Test - fun `M enable session replay W privacy = ALLOW`( + fun `M enable session replay W random privacy settings`( @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, @StringForgery(regex = ".+") customEndpoint: String, @BoolForgery startRecordingImmediately: Boolean ) { - testSessionReplayEnable( - "ALLOW", - replaySampleRate, - customEndpoint, - startRecordingImmediately - ) - } + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() - @Test - fun `M enable session replay W privacy = MASK`( - @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, - @StringForgery(regex = ".+") customEndpoint: String, - @BoolForgery startRecordingImmediately: Boolean - ) { testSessionReplayEnable( - "MASK", - replaySampleRate, - customEndpoint, - startRecordingImmediately - ) - } - - @Test - fun `M enable session replay W privacy = MASK_USER_INPUT`( - @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, - @StringForgery(regex = ".+") customEndpoint: String, - @BoolForgery startRecordingImmediately: Boolean - ) { - testSessionReplayEnable( - "MASK_USER_INPUT", - replaySampleRate, - customEndpoint, - startRecordingImmediately + replaySampleRate = replaySampleRate, + customEndpoint = customEndpoint, + imagePrivacy = imagePrivacy, + touchPrivacy = touchPrivacy, + textAndInputPrivacy = textAndInputPrivacy, + startRecordingImmediately = startRecordingImmediately ) } private fun testSessionReplayEnable( - privacy: String, replaySampleRate: Double, customEndpoint: String, + imagePrivacy: String, + touchPrivacy: String, + textAndInputPrivacy: String, startRecordingImmediately: Boolean ) { // Given @@ -124,8 +120,8 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( replaySampleRate, - privacy, customEndpoint, + SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), startRecordingImmediately, mockPromise ) @@ -135,47 +131,31 @@ internal class DdSessionReplayImplementationTest { assertThat(sessionReplayConfigCaptor.firstValue) .hasFieldEqualTo("sampleRate", replaySampleRate.toFloat()) .hasFieldEqualTo("customEndpointUrl", customEndpoint) - - when (privacy.lowercase(Locale.US)) { - "mask_user_input" -> { - assertThat(sessionReplayConfigCaptor.firstValue) - .hasFieldEqualTo("textAndInputPrivacy", TextAndInputPrivacy.MASK_ALL_INPUTS) - .hasFieldEqualTo("imagePrivacy", ImagePrivacy.MASK_NONE) - .hasFieldEqualTo("touchPrivacy", TouchPrivacy.HIDE) - } - "allow" -> { - assertThat(sessionReplayConfigCaptor.firstValue) - .hasFieldEqualTo( - "textAndInputPrivacy", - TextAndInputPrivacy.MASK_SENSITIVE_INPUTS - ) - .hasFieldEqualTo("imagePrivacy", ImagePrivacy.MASK_NONE) - .hasFieldEqualTo("touchPrivacy", TouchPrivacy.SHOW) - } - else -> { - assertThat(sessionReplayConfigCaptor.firstValue) - .hasFieldEqualTo("textAndInputPrivacy", TextAndInputPrivacy.MASK_ALL) - .hasFieldEqualTo("imagePrivacy", ImagePrivacy.MASK_ALL) - .hasFieldEqualTo("touchPrivacy", TouchPrivacy.HIDE) - } - } + .hasFieldEqualTo("textAndInputPrivacy", inputPrivacyMap[textAndInputPrivacy]) + .hasFieldEqualTo("imagePrivacy", imagePrivacyMap[imagePrivacy]) + .hasFieldEqualTo("touchPrivacy", touchPrivacyMap[touchPrivacy]) } @Test fun `M enable session replay without custom endpoint W empty string()`( @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, - // Not ALLOW nor MASK_USER_INPUT - @StringForgery(regex = "^/(?!ALLOW|MASK_USER_INPUT)([a-z0-9]+)$/i") privacy: String, @BoolForgery startRecordingImmediately: Boolean ) { // Given + val imagePrivacy = imagePrivacyMap.keys.random() + val touchPrivacy = touchPrivacyMap.keys.random() + val textAndInputPrivacy = inputPrivacyMap.keys.random() val sessionReplayConfigCaptor = argumentCaptor() // When testedSessionReplay.enable( replaySampleRate, - privacy, "", + SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), startRecordingImmediately, mockPromise ) diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index 04ba6f9b0..a4f040645 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -33,9 +33,9 @@ public class DdSessionReplayImplementation: NSObject { public func enable( replaySampleRate: Double, customEndpoint: String, - imagePrivacyLevel: String, - touchPrivacyLevel: String, - textAndInputPrivacyLevel: String, + imagePrivacyLevel: NSString, + touchPrivacyLevel: NSString, + textAndInputPrivacyLevel: NSString, startRecordingImmediately: Bool, resolve:RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock @@ -46,9 +46,9 @@ public class DdSessionReplayImplementation: NSObject { } var sessionReplayConfiguration = SessionReplay.Configuration( replaySampleRate: Float(replaySampleRate), + textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel), imagePrivacyLevel: convertImagePrivacy(imagePrivacyLevel), touchPrivacyLevel: convertTouchPrivacy(touchPrivacyLevel), - textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel), startRecordingImmediately: startRecordingImmediately, customEndpoint: customEndpointURL ) diff --git a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift index dd486e449..6626d93c7 100644 --- a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift +++ b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift @@ -12,6 +12,23 @@ import DatadogInternal import React internal class DdSessionReplayTests: XCTestCase { + private let imagePrivacyMap: [String: ImagePrivacyLevel] = [ + "MASK_ALL": .maskAll, + "MASK_NON_BUNDLED_ONLY": .maskNonBundledOnly, + "MASK_NONE": .maskNone + ] + + private let touchPrivacyMap: [String: TouchPrivacyLevel] = [ + "SHOW": .show, + "HIDE": .hide + ] + + private let inputPrivacyMap: [String: TextAndInputPrivacyLevel] = [ + "MASK_ALL": .maskAll, + "MASK_ALL_INPUTS": .maskAllInputs, + "MASK_SENSITIVE_INPUTS": .maskSensitiveInputs + ] + private func mockResolve(args: Any?) {} private func mockReject(args: String?, arg: String?, err: Error?) {} @@ -24,93 +41,59 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithZeroReplaySampleRate() { let sessionReplayMock = MockSessionReplay() let uiManagerMock = MockUIManager() - DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) - .enable(replaySampleRate: 0, defaultPrivacyLevel: "MASK", customEndpoint: "", startRecordingImmediately: true, resolve: mockResolve, reject: mockReject) - - XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( - replaySampleRate: 0.0, - privacyLevel: .mask, - customEndpoint: nil, - startRecordingImmediately: true - )) - } - - func testEnablesSessionReplayWithMaskPrivacyLevel() { - let sessionReplayMock = MockSessionReplay() - let uiManagerMock = MockUIManager() - DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock).enable( - replaySampleRate: 100, - defaultPrivacyLevel: "MASK", - customEndpoint: "", - startRecordingImmediately: true, - resolve: mockResolve, - reject: mockReject - ) - XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( - replaySampleRate: 100.0, - privacyLevel: .mask, - customEndpoint: nil, - startRecordingImmediately: true - )) - } - - func testEnablesSessionReplayWithMaskUserInputPrivacyLevel() { - let sessionReplayMock = MockSessionReplay() - let uiManagerMock = MockUIManager() - DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock).enable( - replaySampleRate: 100, - defaultPrivacyLevel: "MASK_USER_INPUT", - customEndpoint: "", - startRecordingImmediately: true, - resolve: mockResolve, - reject: mockReject - ) + guard + let imagePrivacyLevel = imagePrivacyMap.keys.randomElement(), + let imagePrivacy = imagePrivacyMap[imagePrivacyLevel], + let touchPrivacyLevel = touchPrivacyMap.keys.randomElement(), + let touchPrivacy = touchPrivacyMap[touchPrivacyLevel], + let textAndInputPrivacyLevel = inputPrivacyMap.keys.randomElement(), + let textAndInputPrivacy = inputPrivacyMap[textAndInputPrivacyLevel] + else { + XCTFail("Cannot retrieve privacy levels from maps") + return + } - XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( - replaySampleRate: 100.0, - privacyLevel: .maskUserInput, - customEndpoint: nil, - startRecordingImmediately: true - )) - } - - func testEnablesSessionReplayWithAllowPrivacyLevel() { - let sessionReplayMock = MockSessionReplay() - let uiManagerMock = MockUIManager() DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock).enable( - replaySampleRate: 100, - defaultPrivacyLevel: "ALLOW", + replaySampleRate: 0, customEndpoint: "", + imagePrivacyLevel: NSString(string: imagePrivacyLevel), + touchPrivacyLevel: NSString(string: touchPrivacyLevel), + textAndInputPrivacyLevel: NSString(string: textAndInputPrivacyLevel), startRecordingImmediately: true, resolve: mockResolve, - reject: mockReject - ) - + reject: mockReject) + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( - replaySampleRate: 100.0, - privacyLevel: .allow, + replaySampleRate: 0.0, customEndpoint: nil, + imagePrivacyLevel: imagePrivacy, + touchPrivacyLevel: touchPrivacy, + textAndInputPrivacyLevel: textAndInputPrivacy, startRecordingImmediately: true )) } - func testEnablesSessionReplayWithBadPrivacyLevel() { + func testEnablesSessionReplayWithBadPrivacyLevels() { let sessionReplayMock = MockSessionReplay() let uiManagerMock = MockUIManager() + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock).enable( replaySampleRate: 100, - defaultPrivacyLevel: "BAD_VALUE", customEndpoint: "", + imagePrivacyLevel: "BAD_VALUE", + touchPrivacyLevel: "BAD_VALUE", + textAndInputPrivacyLevel: "BAD_VALUE", startRecordingImmediately: true, resolve: mockResolve, - reject: mockReject - ) + reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( replaySampleRate: 100.0, - privacyLevel: .mask, customEndpoint: nil, + imagePrivacyLevel: .maskAll, + touchPrivacyLevel: .hide, + textAndInputPrivacyLevel: .maskAll, startRecordingImmediately: true )) } @@ -118,65 +101,48 @@ internal class DdSessionReplayTests: XCTestCase { func testEnablesSessionReplayWithCustomEndpoint() { let sessionReplayMock = MockSessionReplay() let uiManagerMock = MockUIManager() + + guard + let imagePrivacyLevel = imagePrivacyMap.keys.randomElement(), + let imagePrivacy = imagePrivacyMap[imagePrivacyLevel], + let touchPrivacyLevel = touchPrivacyMap.keys.randomElement(), + let touchPrivacy = touchPrivacyMap[touchPrivacyLevel], + let textAndInputPrivacyLevel = inputPrivacyMap.keys.randomElement(), + let textAndInputPrivacy = inputPrivacyMap[textAndInputPrivacyLevel] + else { + XCTFail("Cannot retrieve privacy levels from maps") + return + } + DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock).enable( replaySampleRate: 100, - defaultPrivacyLevel: "MASK", customEndpoint: "https://session-replay.example.com", + imagePrivacyLevel: NSString(string: imagePrivacyLevel), + touchPrivacyLevel: NSString(string: touchPrivacyLevel), + textAndInputPrivacyLevel: NSString(string: textAndInputPrivacyLevel), startRecordingImmediately: true, resolve: mockResolve, reject: mockReject) XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable( replaySampleRate: 100.0, - privacyLevel: .mask, customEndpoint: URL(string: "https://session-replay.example.com/api/v2/replay"), + imagePrivacyLevel: imagePrivacy, + touchPrivacyLevel: touchPrivacy, + textAndInputPrivacyLevel: textAndInputPrivacy, startRecordingImmediately: true )) } - - func testStartSessionReplayManually() { - let sessionReplayMock = MockSessionReplay() - let uiManagerMock = MockUIManager() - let sessionReplay = DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) - sessionReplay.enable( - replaySampleRate: 100, - defaultPrivacyLevel: "MASK", - customEndpoint: "https://session-replay.example.com", - startRecordingImmediately: true, - resolve: mockResolve, - reject: mockReject - ) - - sessionReplay.startRecording(resolve: mockResolve, reject: mockReject) - - XCTAssertEqual(sessionReplayMock.calledMethods.last, .startRecording) - } - - func testStopSessionReplayManually() { - let sessionReplayMock = MockSessionReplay() - let uiManagerMock = MockUIManager() - let sessionReplay = DdSessionReplayImplementation(sessionReplayProvider:{ sessionReplayMock }, uiManager: uiManagerMock) - sessionReplay.enable( - replaySampleRate: 100, - defaultPrivacyLevel: "MASK", - customEndpoint: "https://session-replay.example.com", - startRecordingImmediately: true, - resolve: mockResolve, - reject: mockReject - ) - - sessionReplay.stopRecording(resolve: mockResolve, reject: mockReject) - - XCTAssertEqual(sessionReplayMock.calledMethods.last, .stopRecording) - } } private class MockSessionReplay: SessionReplayProtocol { enum CalledMethod: Equatable { case enable( replaySampleRate: Float, - privacyLevel: SessionReplayPrivacyLevel, customEndpoint: URL?, + imagePrivacyLevel: ImagePrivacyLevel, + touchPrivacyLevel: TouchPrivacyLevel, + textAndInputPrivacyLevel: TextAndInputPrivacyLevel, startRecordingImmediately: Bool ) case startRecording @@ -189,8 +155,10 @@ private class MockSessionReplay: SessionReplayProtocol { calledMethods.append( .enable( replaySampleRate: configuration.replaySampleRate, - privacyLevel: configuration.defaultPrivacyLevel, customEndpoint: configuration.customEndpoint, + imagePrivacyLevel: configuration.imagePrivacyLevel, + touchPrivacyLevel: configuration.touchPrivacyLevel, + textAndInputPrivacyLevel: configuration.textAndInputPrivacyLevel, startRecordingImmediately: configuration.startRecordingImmediately ) ) diff --git a/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts b/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts index a1ed2e01b..fe5003782 100644 --- a/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts +++ b/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts @@ -6,7 +6,21 @@ import { NativeModules } from 'react-native'; -import { SessionReplay, SessionReplayPrivacy } from '../SessionReplay'; +import { + ImagePrivacyLevel, + SessionReplay, + SessionReplayPrivacy, + TextAndInputPrivacyLevel, + TouchPrivacyLevel +} from '../SessionReplay'; + +function getRandomEnumValue< + T extends { [s: string]: T[keyof T] } | ArrayLike +>(enumObj: T): T[keyof T] { + const values = Object.values(enumObj) as T[keyof T][]; // Get all enum values + const randomIndex = Math.floor(Math.random() * values.length); // Generate a random index + return values[randomIndex]; // Return the random value +} beforeEach(() => { NativeModules.DdSessionReplay.enable.mockClear(); @@ -19,13 +33,15 @@ describe('SessionReplay', () => { expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 0, - 'MASK', '', + 'MASK_ALL', + 'HIDE', + 'MASK_ALL', true ); }); - it('calls native session replay with provided configuration', () => { + it('calls native session replay with provided configuration { w defaultPrivacyLevel = ALLOW }', () => { SessionReplay.enable({ replaySampleRate: 100, defaultPrivacyLevel: SessionReplayPrivacy.ALLOW, @@ -34,34 +50,90 @@ describe('SessionReplay', () => { expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 100, - 'ALLOW', 'https://session-replay.example.com', + 'MASK_NONE', + 'SHOW', + 'MASK_SENSITIVE_INPUTS', true ); }); - it('calls native session replay with edge cases in configuration', () => { + it('calls native session replay with provided configuration { w defaultPrivacyLevel = MASK }', () => { SessionReplay.enable({ - replaySampleRate: 0, - customEndpoint: '' + replaySampleRate: 100, + defaultPrivacyLevel: SessionReplayPrivacy.MASK, + customEndpoint: 'https://session-replay.example.com' }); expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( - 0, - 'MASK', - '', + 100, + 'https://session-replay.example.com', + 'MASK_ALL', + 'HIDE', + 'MASK_ALL', true ); }); - it('calls native session replay with start immediately = false', () => { - SessionReplay.enable({ startRecordingImmediately: false }); + it('calls native session replay with provided configuration { w defaultPrivacyLevel = MASK_USER_INPUT }', () => { + SessionReplay.enable({ + replaySampleRate: 100, + defaultPrivacyLevel: SessionReplayPrivacy.MASK_USER_INPUT, + customEndpoint: 'https://session-replay.example.com' + }); + + expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( + 100, + 'https://session-replay.example.com', + 'MASK_NONE', + 'HIDE', + 'MASK_ALL_INPUTS', + true + ); + }); + + it('calls native session replay with provided configuration { w random privacy levels }', () => { + const TIMES = 20; + + const image = getRandomEnumValue(ImagePrivacyLevel); + const touch = getRandomEnumValue(TouchPrivacyLevel); + const textAndInput = getRandomEnumValue(TextAndInputPrivacyLevel); + + for (let i = 0; i < TIMES; ++i) { + SessionReplay.enable({ + replaySampleRate: 100, + customEndpoint: 'https://session-replay.example.com', + imagePrivacyLevel: image, + touchPrivacyLevel: touch, + textAndInputPrivacyLevel: textAndInput + }); + + expect( + NativeModules.DdSessionReplay.enable + ).toHaveBeenCalledWith( + 100, + 'https://session-replay.example.com', + image, + touch, + textAndInput, + true + ); + } + }); + + it('calls native session replay with edge cases in configuration', () => { + SessionReplay.enable({ + replaySampleRate: 0, + customEndpoint: '' + }); expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 0, - 'MASK', '', - false + 'MASK_ALL', + 'HIDE', + 'MASK_ALL', + true ); }); }); From 5a2e59e122fbf4798218f963d2a73d07317cc781 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 19 Nov 2024 13:04:53 +0100 Subject: [PATCH 3/3] Warning logs for unknown SR privacy levels --- .../SessionReplayPrivacySettings.kt | 21 +++++++++++++++---- .../DdSessionReplayImplementation.swift | 6 +++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt index ef4975a05..5af4819fe 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayPrivacySettings.kt @@ -6,6 +6,7 @@ package com.datadog.reactnative.sessionreplay +import android.util.Log import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.TouchPrivacy @@ -45,7 +46,11 @@ class SessionReplayPrivacySettings( "MASK_ALL" -> ImagePrivacy.MASK_ALL "MASK_NONE" -> ImagePrivacy.MASK_NONE else -> { - // TODO: Log wrong usage / mapping. + Log.w( + SessionReplayPrivacySettings::class.java.canonicalName, + "Unknown Session Replay Image Privacy Level given: $imagePrivacyLevel, " + + "using ${ImagePrivacy.MASK_ALL} as default" + ) ImagePrivacy.MASK_ALL } } @@ -56,7 +61,11 @@ class SessionReplayPrivacySettings( "SHOW" -> TouchPrivacy.SHOW "HIDE" -> TouchPrivacy.HIDE else -> { - // TODO: Log wrong usage / mapping. + Log.w( + SessionReplayPrivacySettings::class.java.canonicalName, + "Unknown Session Replay Touch Privacy Level given: $touchPrivacyLevel, " + + "using ${TouchPrivacy.HIDE} as default" + ) TouchPrivacy.HIDE } } @@ -68,10 +77,14 @@ class SessionReplayPrivacySettings( "MASK_ALL_INPUTS" -> TextAndInputPrivacy.MASK_ALL_INPUTS "MASK_ALL" -> TextAndInputPrivacy.MASK_ALL else -> { - // TODO: Log wrong usage / mapping + Log.w( + SessionReplayPrivacySettings::class.java.canonicalName, + "Unknown Session Replay Text And Input Privacy Level given: $textAndInputPrivacyLevel, " + + "using ${TextAndInputPrivacy.MASK_ALL} as default" + ) TextAndInputPrivacy.MASK_ALL } } } } -} \ No newline at end of file +} diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index a4f040645..ffa627d79 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -98,7 +98,7 @@ public class DdSessionReplayImplementation: NSObject { case "MASK_NONE": return .maskNone default: - // TODO: Log wrong usage / mapping + consolePrint("Unknown Session Replay Image Privacy Level given: \(imagePrivacy), using .maskAll as default.", .warn) return .maskAll } } @@ -110,7 +110,7 @@ public class DdSessionReplayImplementation: NSObject { case "HIDE": return .hide default: - // TODO: Log wrong usage / mapping + consolePrint("Unknown Session Replay Touch Privacy Level given: \(touchPrivacy), using .hide as default.", .warn) return .hide } } @@ -124,7 +124,7 @@ public class DdSessionReplayImplementation: NSObject { case "MASK_ALL": return .maskAll default: - // TODO: Log wrong usage / mapping + consolePrint("Unknown Session Replay Text and Input Privacy Level given: \(textAndInputPrivacy), using .maskAll as default.", .warn) return .maskAll } }