From 254a14f55d9a393074382b250cc668d28f1f0369 Mon Sep 17 00:00:00 2001 From: louiszawadzki Date: Wed, 25 Oct 2023 17:53:01 +0200 Subject: [PATCH 1/2] Enable session replay on Android --- .../android/build.gradle | 1 + .../DdSessionReplayImplementation.kt | 25 +++++++++- .../sessionreplay/SessionReplaySDKWrapper.kt | 24 ++++++++++ .../sessionreplay/SessionReplayWrapper.kt | 22 +++++++++ .../DdSessionReplayImplementationTest.kt | 48 +++++++++++++++++-- 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplaySDKWrapper.kt create mode 100644 packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayWrapper.kt diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 14bc0dae7..97ba8d23b 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -167,6 +167,7 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.datadoghq:dd-sdk-android-session-replay:2.0.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2" 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 8dbd4d217..662af4315 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 @@ -6,21 +6,44 @@ package com.datadog.reactnative.sessionreplay +import com.datadog.android.sessionreplay.SessionReplayConfiguration +import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.facebook.react.bridge.Promise +import java.util.Locale /** * The entry point to use Datadog's Session Replay feature. */ -class DdSessionReplayImplementation() { +class DdSessionReplayImplementation( + private val sessionReplayProvider: () -> SessionReplayWrapper = { + SessionReplaySDKWrapper() + } +) { /** * Enable session replay and start recording session. * @param replaySampleRate The sample rate applied for session replay. * @param defaultPrivacyLevel The privacy level used for replay. */ fun enable(replaySampleRate: Double, defaultPrivacyLevel: String, promise: Promise) { + val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) + .setPrivacy(buildPrivacy(defaultPrivacyLevel)) + .build() + sessionReplayProvider().enable(configuration) promise.resolve(null) } + private fun buildPrivacy(defaultPrivacyLevel: String): SessionReplayPrivacy { + return when (defaultPrivacyLevel?.lowercase(Locale.US)) { + "mask" -> SessionReplayPrivacy.MASK + "mask_user_input" -> SessionReplayPrivacy.MASK_USER_INPUT + "allow" -> SessionReplayPrivacy.ALLOW + else -> { + SessionReplayPrivacy.MASK + } + } + + } + companion object { internal const val NAME = "DdSessionReplay" } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplaySDKWrapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplaySDKWrapper.kt new file mode 100644 index 000000000..7fa70e152 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplaySDKWrapper.kt @@ -0,0 +1,24 @@ +/* + * 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.SessionReplay +import com.datadog.android.sessionreplay.SessionReplayConfiguration + +internal class SessionReplaySDKWrapper : SessionReplayWrapper { + /** + * Enables a SessionReplay feature based on the configuration provided. + * @param sessionReplayConfiguration Configuration to use for the feature. + */ + override fun enable( + sessionReplayConfiguration: SessionReplayConfiguration, + ) { + SessionReplay.enable( + sessionReplayConfiguration, + ) + } +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayWrapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayWrapper.kt new file mode 100644 index 000000000..2f395c637 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/SessionReplayWrapper.kt @@ -0,0 +1,22 @@ +/* + * 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.SessionReplayConfiguration + +/** + * Wrapper around [SessionReplay]. + */ +interface SessionReplayWrapper { + /** + * Enables a SessionReplay feature based on the configuration provided. + * @param sessionReplayConfiguration Configuration to use for the feature. + */ + fun enable( + sessionReplayConfiguration: SessionReplayConfiguration, + ) +} 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 57ccb69e2..67773f936 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 @@ -6,7 +6,15 @@ package com.datadog.reactnative.sessionreplay +import com.datadog.android.sessionreplay.SessionReplayConfiguration +import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.tools.unit.GenericAssert.Companion.assertThat import com.facebook.react.bridge.Promise +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.verify +import fr.xgouchet.elmyr.annotation.DoubleForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -30,9 +38,12 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockPromise: Promise + @Mock + lateinit var mockSessionReplay: SessionReplayWrapper + @BeforeEach fun `set up`() { - testedSessionReplay = DdSessionReplayImplementation() + testedSessionReplay = DdSessionReplayImplementation { mockSessionReplay } } @AfterEach @@ -40,8 +51,39 @@ internal class DdSessionReplayImplementationTest { } @Test - fun `M do nothing W enable()`() { + fun `M enable session replay W enable()`( + @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, + @Forgery privacy: SessionReplayPrivacy + ) { + // Given + val sessionReplayConfigCaptor = argumentCaptor() + + // When + testedSessionReplay.enable(replaySampleRate, privacy.toString(), mockPromise) + + // Then + verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture()) + assertThat(sessionReplayConfigCaptor.firstValue) + .hasFieldEqualTo("sampleRate", replaySampleRate.toFloat()) + .hasFieldEqualTo("privacy", privacy) + } + + @Test + fun `M enable session replay with mask W enable with bad privacy option()`( + @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, + ) { + // Given + val sessionReplayConfigCaptor = argumentCaptor() + // When - testedSessionReplay.enable(100.0, "MASK", mockPromise) + testedSessionReplay.enable(replaySampleRate, privacy, mockPromise) + + // Then + verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture()) + assertThat(sessionReplayConfigCaptor.firstValue) + .hasFieldEqualTo("sampleRate", replaySampleRate.toFloat()) + .hasFieldEqualTo("privacy", SessionReplayPrivacy.MASK) } } From 3b509bb47992634d7f27261fa1965bd3ae633875 Mon Sep 17 00:00:00 2001 From: louiszawadzki Date: Thu, 26 Oct 2023 10:16:23 +0200 Subject: [PATCH 2/2] Enable Session Replay on iOS --- example/ios/Podfile | 1 + example/ios/Podfile.lock | 17 +++++- ...DatadogSDKReactNativeSessionReplay.podspec | 1 + .../DdSessionReplayImplementation.swift | 45 ++++++++++++++ .../ios/Tests/DdSessionReplayTests.swift | 61 +++++++++++++++++-- 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/example/ios/Podfile b/example/ios/Podfile index 64c60a6cf..7f734117b 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -23,6 +23,7 @@ end target 'ddSdkReactnativeExample' do pod 'DatadogSDKReactNative', :path => '../../packages/core/DatadogSDKReactNative.podspec', :testspecs => ['Tests'] + pod 'DatadogSDKReactNativeSessionReplay', :path => '../../packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec', :testspecs => ['Tests'] config = use_native_modules! diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 4744edc89..a3b1533c2 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -26,6 +26,14 @@ PODS: - DatadogTrace (~> 2.2.1) - DatadogWebViewTracking (~> 2.2.1) - React-Core + - DatadogSDKReactNativeSessionReplay (1.8.5): + - DatadogSessionReplay (~> 2.2.1) + - React-Core + - DatadogSDKReactNativeSessionReplay/Tests (1.8.5): + - DatadogSessionReplay (~> 2.2.1) + - React-Core + - DatadogSessionReplay (2.2.1): + - DatadogInternal (= 2.2.1) - DatadogTrace (2.2.1): - DatadogInternal (= 2.2.1) - DatadogWebViewTracking (2.2.1): @@ -411,6 +419,8 @@ DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - DatadogSDKReactNative (from `../../packages/core/DatadogSDKReactNative.podspec`) - DatadogSDKReactNative/Tests (from `../../packages/core/DatadogSDKReactNative.podspec`) + - DatadogSDKReactNativeSessionReplay (from `../../packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec`) + - DatadogSDKReactNativeSessionReplay/Tests (from `../../packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`) @@ -461,6 +471,7 @@ SPEC REPOS: - DatadogInternal - DatadogLogs - DatadogRUM + - DatadogSessionReplay - DatadogTrace - DatadogWebViewTracking - fmt @@ -473,6 +484,8 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" DatadogSDKReactNative: :path: "../../packages/core/DatadogSDKReactNative.podspec" + DatadogSDKReactNativeSessionReplay: + :path: "../../packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" FBLazyVector: @@ -562,6 +575,8 @@ SPEC CHECKSUMS: DatadogLogs: a0eafa7bd2103511eac07bcd2ff95c851123e29b DatadogRUM: 1e027ccfe4ba1eb81a185f3c58e0909bb12811be DatadogSDKReactNative: 6f16f15e8b3d5a60c5799d604843a0feb2010c9b + DatadogSDKReactNativeSessionReplay: ec9e93b87abbb2f4935bd02e651b28a9503b48de + DatadogSessionReplay: 4b29318297ad20189a69153b4b0475f0fab7d3f5 DatadogTrace: 74dc91a7a80e746dc4ef1af6d0db1735b5bfd993 DatadogWebViewTracking: 9ca93299a2c900c68ba080f6e800fae1fa3c6b61 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 @@ -609,6 +624,6 @@ SPEC CHECKSUMS: RNScreens: f7ad633b2e0190b77b6a7aab7f914fad6f198d8d Yoga: e7ea9e590e27460d28911403b894722354d73479 -PODFILE CHECKSUM: 59a4878659fbb7b053887dd9eec3df44ca9e0b28 +PODFILE CHECKSUM: c13458ce5aca9de130799f13a0494e537a800b3c COCOAPODS: 1.12.1 diff --git a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec index 139a9148c..9f734ff70 100644 --- a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec +++ b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec @@ -17,6 +17,7 @@ Pod::Spec.new do |s| s.source_files = "ios/Sources/*.{h,m,mm,swift}" s.dependency "React-Core" + s.dependency 'DatadogSessionReplay', '~> 2.2.1' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/*.swift' diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index fc1454342..61515b9b7 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -5,11 +5,56 @@ */ import Foundation +import DatadogSessionReplay +import DatadogInternal @objc public class DdSessionReplayImplementation: NSObject { + private lazy var sessionReplay: SessionReplayProtocol = sessionReplayProvider() + private let sessionReplayProvider: () -> SessionReplayProtocol + + internal init(_ sessionReplayProvider: @escaping () -> SessionReplayProtocol) { + self.sessionReplayProvider = sessionReplayProvider + } + + @objc + public override convenience init() { + self.init({ NativeSessionReplay() }) + } + @objc public func enable(replaySampleRate: Double, defaultPrivacyLevel: String, resolve:RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock) -> Void { + sessionReplay.enable( + with: SessionReplay.Configuration( + replaySampleRate: Float(replaySampleRate), + defaultPrivacyLevel: buildPrivacyLevel(privacyLevel: defaultPrivacyLevel as NSString) + ) + ) resolve(nil) } + + func buildPrivacyLevel(privacyLevel: NSString) -> SessionReplay.Configuration.PrivacyLevel { + switch privacyLevel.lowercased { + case "mask": + return .mask + case "mask_user_input": + return .maskUserInput + case "allow": + return .allow + default: + return .mask + } + } +} + +internal protocol SessionReplayProtocol { + func enable( + with configuration: SessionReplay.Configuration + ) +} + +internal class NativeSessionReplay: SessionReplayProtocol { + func enable(with configuration: DatadogSessionReplay.SessionReplay.Configuration) { + SessionReplay.enable(with: configuration) + } } diff --git a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift index 5d85d7967..0af63ffaf 100644 --- a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift +++ b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift @@ -5,14 +5,67 @@ */ import XCTest +@testable import DatadogSDKReactNativeSessionReplay +import DatadogSessionReplay internal class DdSessionReplayTests: XCTestCase { - private lazy var sessionReplay = DdSessionReplayImplementation() - private func mockResolve(args: Any?) {} private func mockReject(args: String?, arg: String?, err: Error?) {} - func testDoesNothing() { - sessionReplay.enable(replaySampleRate: 100, defaultPrivacyLevel: "MASK", resolve: mockResolve, reject: mockReject) + func testEnablesSessionReplayWithZeroReplaySampleRate() { + let sessionReplayMock = MockSessionReplay() + DdSessionReplayImplementation({ sessionReplayMock }) + .enable(replaySampleRate: 0, defaultPrivacyLevel: "MASK", resolve: mockResolve, reject: mockReject) + + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 0.0, privacyLevel: .mask)) + } + + func testEnablesSessionReplayWithMaskPrivacyLevel() { + let sessionReplayMock = MockSessionReplay() + DdSessionReplayImplementation({ sessionReplayMock }) + .enable(replaySampleRate: 100, defaultPrivacyLevel: "MASK", resolve: mockResolve, reject: mockReject) + + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .mask)) + } + + func testEnablesSessionReplayWithMaskUserInputPrivacyLevel() { + let sessionReplayMock = MockSessionReplay() + DdSessionReplayImplementation({ sessionReplayMock }) + .enable(replaySampleRate: 100, defaultPrivacyLevel: "MASK_USER_INPUT", resolve: mockResolve, reject: mockReject) + + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .maskUserInput)) + } + + func testEnablesSessionReplayWithAllowPrivacyLevel() { + let sessionReplayMock = MockSessionReplay() + DdSessionReplayImplementation({ sessionReplayMock }) + .enable(replaySampleRate: 100, defaultPrivacyLevel: "ALLOW", resolve: mockResolve, reject: mockReject) + + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .allow)) + } + + func testEnablesSessionReplayWithBadPrivacyLevel() { + let sessionReplayMock = MockSessionReplay() + DdSessionReplayImplementation({ sessionReplayMock }) + .enable(replaySampleRate: 100, defaultPrivacyLevel: "BAD_VALUE", resolve: mockResolve, reject: mockReject) + + XCTAssertEqual(sessionReplayMock.calledMethods.first, .enable(replaySampleRate: 100.0, privacyLevel: .mask)) + } +} + +private class MockSessionReplay: SessionReplayProtocol { + enum CalledMethod: Equatable { + case enable(replaySampleRate: Float, privacyLevel: SessionReplay.Configuration.PrivacyLevel) + } + + public var calledMethods = [CalledMethod]() + + func enable(with configuration: SessionReplay.Configuration) { + calledMethods.append( + .enable( + replaySampleRate: configuration.replaySampleRate, + privacyLevel: configuration.defaultPrivacyLevel + ) + ) } }