Skip to content

Commit

Permalink
Merge pull request #739 from DataDog/marcosaia/RUM-7181/sr-fgm-config
Browse files Browse the repository at this point in the history
[RUM-7181] SR: Fine Grained Masking privacy configuration
  • Loading branch information
marco-saia-datadog authored Nov 19, 2024
2 parents ea9eb8d + 5a2e59e commit 7e201e3
Show file tree
Hide file tree
Showing 12 changed files with 537 additions and 255 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,25 @@ 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 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,
defaultPrivacyLevel: String,
customEndpoint: String,
privacySettings: SessionReplayPrivacySettings,
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(privacySettings.imagePrivacyLevel)
.setTouchPrivacy(privacySettings.touchPrivacyLevel)
.setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel)
.addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger))

if (customEndpoint != "") {
Expand Down Expand Up @@ -74,32 +77,6 @@ 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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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 android.util.Log
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 -> {
Log.w(
SessionReplayPrivacySettings::class.java.canonicalName,
"Unknown Session Replay Image Privacy Level given: $imagePrivacyLevel, " +
"using ${ImagePrivacy.MASK_ALL} as default"
)
ImagePrivacy.MASK_ALL
}
}
}

internal fun getTouchPrivacy(touchPrivacyLevel: String): TouchPrivacy {
return when (touchPrivacyLevel) {
"SHOW" -> TouchPrivacy.SHOW
"HIDE" -> TouchPrivacy.HIDE
else -> {
Log.w(
SessionReplayPrivacySettings::class.java.canonicalName,
"Unknown Session Replay Touch Privacy Level given: $touchPrivacyLevel, " +
"using ${TouchPrivacy.HIDE} as default"
)
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 -> {
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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,28 @@ 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,
SessionReplayPrivacySettings(
imagePrivacyLevel = imagePrivacyLevel,
touchPrivacyLevel = touchPrivacyLevel,
textAndInputPrivacyLevel = textAndInputPrivacyLevel
),
promise
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,30 @@ 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,
SessionReplayPrivacySettings(
imagePrivacyLevel = imagePrivacyLevel,
touchPrivacyLevel = touchPrivacyLevel,
textAndInputPrivacyLevel = textAndInputPrivacyLevel
),
startRecordingImmediately,
promise
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Class<NativeModule>>()))
Expand All @@ -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
Expand All @@ -124,8 +120,8 @@ internal class DdSessionReplayImplementationTest {
// When
testedSessionReplay.enable(
replaySampleRate,
privacy,
customEndpoint,
SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy),
startRecordingImmediately,
mockPromise
)
Expand All @@ -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<SessionReplayConfiguration>()

// When
testedSessionReplay.enable(
replaySampleRate,
privacy,
"",
SessionReplayPrivacySettings(
imagePrivacyLevel = imagePrivacy,
touchPrivacyLevel = touchPrivacy,
textAndInputPrivacyLevel = textAndInputPrivacy
),
startRecordingImmediately,
mockPromise
)
Expand Down
Loading

0 comments on commit 7e201e3

Please sign in to comment.