diff --git a/benchmarks/android/app/build.gradle b/benchmarks/android/app/build.gradle index 1a4f94b95..98708153a 100644 --- a/benchmarks/android/app/build.gradle +++ b/benchmarks/android/app/build.gradle @@ -129,7 +129,7 @@ dependencies { // Benchmark tools from dd-sdk-android are used for vitals recording // Remember to bump this alongside the main dd-sdk-android dependencies - implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.15.0") + implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.16.0") // Required for compile-time access to Datadog.getInstance() required by BenchmarkVitalsModuleImpl - implementation("com.datadoghq:dd-sdk-android-core:3.15.0") + implementation("com.datadoghq:dd-sdk-android-core:3.16.0") } diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 5b4306714..8f92d34bb 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -222,7 +222,7 @@ dependencies { // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. // To avoid this, we enforce 1.0.0-beta01 on RN < 0.76.0 if (reactNativeMinorVersion < 76) { - implementation("com.datadoghq:dd-sdk-android-rum:3.15.0") { + implementation("com.datadoghq:dd-sdk-android-rum:3.16.0") { exclude group: "androidx.metrics", module: "metrics-performance" } implementation "androidx.metrics:metrics-performance:1.0.0-beta01" @@ -242,15 +242,15 @@ dependencies { } } } else { - implementation "com.datadoghq:dd-sdk-android-rum:3.15.0" + implementation "com.datadoghq:dd-sdk-android-rum:3.16.0" } - implementation "com.datadoghq:dd-sdk-android-logs:3.15.0" - implementation "com.datadoghq:dd-sdk-android-trace:3.15.0" - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" - implementation "com.datadoghq:dd-sdk-android-ndk:3.15.0" - implementation "com.datadoghq:dd-sdk-android-flags:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" - implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.15.0" + implementation "com.datadoghq:dd-sdk-android-logs:3.16.0" + implementation "com.datadoghq:dd-sdk-android-trace:3.16.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" + implementation "com.datadoghq:dd-sdk-android-ndk:3.16.0" + implementation "com.datadoghq:dd-sdk-android-flags:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" + implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.16.0" implementation "com.google.code.gson:gson:2.11.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/internal-testing-tools/android/build.gradle b/packages/internal-testing-tools/android/build.gradle index 373d6b5fa..e24ad5b54 100644 --- a/packages/internal-testing-tools/android/build.gradle +++ b/packages/internal-testing-tools/android/build.gradle @@ -197,7 +197,7 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native') - implementation "com.datadoghq:dd-sdk-android-core:3.15.0" + implementation "com.datadoghq:dd-sdk-android-core:3.16.0" implementation "com.google.code.gson:gson:2.11.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 335aaa590..c14147adb 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -224,8 +224,8 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.datadoghq:dd-sdk-android-session-replay:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" + implementation "com.datadoghq:dd-sdk-android-session-replay:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" implementation project(path: ':datadog_mobile-react-native') testImplementation "org.junit.platform:junit-platform-launcher:1.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 7cf8e88ae..c8d679802 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 @@ -33,6 +33,7 @@ class DdSessionReplayImplementation( * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. * @param enableHeatmaps Enables heatmap identifier computation. * @param enableCompositionTreeRecording Enables the Composition Tree recording pipeline. + * @param adaptiveCaptureSchedulingEnabled Enables experimental adaptive capture scheduling on Android. */ @SuppressLint("VisibleForTests") @Suppress("LongParameterList", "UNUSED_PARAMETER") @@ -43,28 +44,30 @@ class DdSessionReplayImplementation( startRecordingImmediately: Boolean, enableHeatmaps: Boolean, enableCompositionTreeRecording: Boolean, + adaptiveCaptureSchedulingEnabled: Boolean, promise: Promise ) { val sdkCore = Datadog.getInstance() as FeatureSdkCore val logger = sdkCore.internalLogger val textViewUtils = TextViewUtils.create(reactContext, logger) val internalCallback = ReactNativeInternalCallback(reactContext) - val configuration = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) + val configurationBuilder = SessionReplayConfiguration.Builder(replaySampleRate.toFloat()) .startRecordingImmediately(startRecordingImmediately) .setImagePrivacy(privacySettings.imagePrivacyLevel) .setTouchPrivacy(privacySettings.touchPrivacyLevel) .setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel) .setHeatmapsEnabled(enableHeatmaps) .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(textViewUtils, internalCallback)) - .let { - _SessionReplayInternalProxy(it).setInternalCallback(internalCallback) - } + + val internalProxy = _SessionReplayInternalProxy(configurationBuilder) + internalProxy.setInternalCallback(internalCallback) + internalProxy.setAdaptiveCaptureSchedulingEnabled(adaptiveCaptureSchedulingEnabled) if (customEndpoint != "") { - configuration.useCustomEndpoint(customEndpoint) + configurationBuilder.useCustomEndpoint(customEndpoint) } - sessionReplayProvider().enable(configuration.build(), sdkCore) + sessionReplayProvider().enable(configurationBuilder.build(), sdkCore) HeatmapActionHandler.heatmapsEnabled = enableHeatmaps promise.resolve(null) 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 a3c2a77de..5df7180d8 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 @@ -31,6 +31,7 @@ class DdSessionReplay( * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. * @param enableHeatmaps Enables heatmap identifier computation. * @param enableCompositionTreeRecording Enables the Composition Tree recording pipeline. + * @param adaptiveCaptureSchedulingEnabled Enables experimental adaptive capture scheduling on Android. */ @Suppress("LongParameterList") @ReactMethod @@ -43,20 +44,22 @@ class DdSessionReplay( startRecordingImmediately: Boolean, enableHeatmaps: Boolean, enableCompositionTreeRecording: Boolean, + adaptiveCaptureSchedulingEnabled: Boolean, promise: Promise ) { implementation.enable( - replaySampleRate, - customEndpoint, - SessionReplayPrivacySettings( + replaySampleRate = replaySampleRate, + customEndpoint = customEndpoint, + privacySettings = SessionReplayPrivacySettings( imagePrivacyLevel = imagePrivacyLevel, touchPrivacyLevel = touchPrivacyLevel, textAndInputPrivacyLevel = textAndInputPrivacyLevel ), - startRecordingImmediately, - enableHeatmaps, - enableCompositionTreeRecording, - promise + startRecordingImmediately = startRecordingImmediately, + enableHeatmaps = enableHeatmaps, + enableCompositionTreeRecording = enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled = adaptiveCaptureSchedulingEnabled, + promise = 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 171d7ff65..3a0cda70e 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 @@ -32,6 +32,7 @@ class DdSessionReplay( * @param startRecordingImmediately Whether the recording should start immediately when the feature is enabled. * @param enableHeatmaps Enables heatmap identifier computation. * @param enableCompositionTreeRecording Enables the Composition Tree recording pipeline. + * @param adaptiveCaptureSchedulingEnabled Enables experimental adaptive capture scheduling on Android. */ @Suppress("LongParameterList") @ReactMethod @@ -44,20 +45,22 @@ class DdSessionReplay( startRecordingImmediately: Boolean, enableHeatmaps: Boolean, enableCompositionTreeRecording: Boolean, + adaptiveCaptureSchedulingEnabled: Boolean, promise: Promise ) { implementation.enable( - replaySampleRate, - customEndpoint, - SessionReplayPrivacySettings( + replaySampleRate = replaySampleRate, + customEndpoint = customEndpoint, + privacySettings = SessionReplayPrivacySettings( imagePrivacyLevel = imagePrivacyLevel, touchPrivacyLevel = touchPrivacyLevel, textAndInputPrivacyLevel = textAndInputPrivacyLevel ), - startRecordingImmediately, - enableHeatmaps, - enableCompositionTreeRecording, - promise + startRecordingImmediately = startRecordingImmediately, + enableHeatmaps = enableHeatmaps, + enableCompositionTreeRecording = enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled = adaptiveCaptureSchedulingEnabled, + promise = 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 42c645754..e2f58e449 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 @@ -30,6 +30,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -132,13 +134,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - customEndpoint, - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, - false, - false, - mockPromise + replaySampleRate = replaySampleRate, + customEndpoint = customEndpoint, + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, + enableHeatmaps = false, + enableCompositionTreeRecording = false, + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then @@ -164,17 +171,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings( + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( imagePrivacyLevel = imagePrivacy, touchPrivacyLevel = touchPrivacy, textAndInputPrivacyLevel = textAndInputPrivacy ), - startRecordingImmediately, - false, - false, - mockPromise + startRecordingImmediately = startRecordingImmediately, + enableHeatmaps = false, + enableCompositionTreeRecording = false, + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then @@ -186,6 +194,32 @@ internal class DdSessionReplayImplementationTest { .doesNotHaveField("customEndpointUrl") } + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M configure adaptive capture scheduling W enable`(enabled: Boolean) { + val sessionReplayConfigCaptor = argumentCaptor() + + testedSessionReplay.enable( + replaySampleRate = 100.0, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = "MASK_ALL", + touchPrivacyLevel = "HIDE", + textAndInputPrivacyLevel = "MASK_ALL" + ), + startRecordingImmediately = true, + enableHeatmaps = false, + enableCompositionTreeRecording = false, + adaptiveCaptureSchedulingEnabled = enabled, + promise = mockPromise + ) + + verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture(), any()) + assertThat(sessionReplayConfigCaptor.firstValue) + .hasFieldEqualTo("adaptiveCaptureSchedulingEnabled", enabled) + verify(mockPromise).resolve(null) + } + // region enableHeatmaps flag @Test @@ -200,13 +234,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, enableHeatmaps = true, enableCompositionTreeRecording = false, - mockPromise + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then @@ -226,13 +265,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, enableHeatmaps = false, enableCompositionTreeRecording = false, - mockPromise + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then @@ -252,13 +296,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, enableHeatmaps = true, enableCompositionTreeRecording = false, - mockPromise + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then — heatmapsEnabled is true in the built SessionReplayConfiguration @@ -280,13 +329,18 @@ internal class DdSessionReplayImplementationTest { // When testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, enableHeatmaps = false, enableCompositionTreeRecording = false, - mockPromise + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) // Then — heatmapsEnabled is false in the built SessionReplayConfiguration @@ -309,13 +363,18 @@ internal class DdSessionReplayImplementationTest { // When / Then assertThrows(IllegalStateException::class.java) { testedSessionReplay.enable( - replaySampleRate, - "", - SessionReplayPrivacySettings(imagePrivacy, touchPrivacy, textAndInputPrivacy), - startRecordingImmediately, + replaySampleRate = replaySampleRate, + customEndpoint = "", + privacySettings = SessionReplayPrivacySettings( + imagePrivacyLevel = imagePrivacy, + touchPrivacyLevel = touchPrivacy, + textAndInputPrivacyLevel = textAndInputPrivacy + ), + startRecordingImmediately = startRecordingImmediately, enableHeatmaps = true, enableCompositionTreeRecording = false, - mockPromise + adaptiveCaptureSchedulingEnabled = false, + promise = mockPromise ) } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayTest.kt new file mode 100644 index 000000000..99ec7511d --- /dev/null +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayTest.kt @@ -0,0 +1,110 @@ +/* + * 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.content.res.AssetManager +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.SessionReplay +import com.datadog.android.sessionreplay.SessionReplayConfiguration +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.reactnative.HeatmapActionHandler +import com.datadog.tools.unit.GenericAssert.Companion.assertThat +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.UIManagerModule +import java.io.IOException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mock +import org.mockito.Mockito.mockStatic +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class DdSessionReplayTest { + + @Mock + lateinit var mockReactContext: ReactApplicationContext + + @Mock + lateinit var mockPromise: Promise + + @Mock + lateinit var mockUiManagerModule: UIManagerModule + + @Mock + lateinit var mockAssetManager: AssetManager + + @BeforeEach + fun `set up`() { + whenever(mockReactContext.getNativeModule(any>())) + .doReturn(mockUiManagerModule) + whenever(mockReactContext.assets).doReturn(mockAssetManager) + whenever(mockAssetManager.open(any())).doThrow(IOException("No assets in test")) + } + + @AfterEach + fun `tear down`() { + HeatmapActionHandler.heatmapsEnabled = false + } + + // This test runs against whichever bridge (old or new architecture) is selected by Gradle. + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M forward adaptive capture scheduling to native SDK W enable on bridge`( + adaptiveCaptureSchedulingEnabled: Boolean + ) { + val configurationCaptor = argumentCaptor() + val otherOptionsEnabled = !adaptiveCaptureSchedulingEnabled + + mockStatic(SessionReplay::class.java).use { sessionReplay -> + // Positional arguments mirror the JavaScript/TurboModule contract. + // Opposite values for other options detect swapped boolean arguments. + DdSessionReplay(mockReactContext).enable( + 37.0, // replaySampleRate + "https://session-replay.example.com", // customEndpoint + "MASK_NONE", // imagePrivacyLevel + "SHOW", // touchPrivacyLevel + "MASK_ALL_INPUTS", // textAndInputPrivacyLevel + otherOptionsEnabled, // startRecordingImmediately + otherOptionsEnabled, // enableHeatmaps + otherOptionsEnabled, // enableCompositionTreeRecording + adaptiveCaptureSchedulingEnabled, + mockPromise // promise + ) + + sessionReplay.verify { SessionReplay.enable(configurationCaptor.capture(), any()) } + assertThat(configurationCaptor.firstValue) + .hasFieldEqualTo( + "adaptiveCaptureSchedulingEnabled", + adaptiveCaptureSchedulingEnabled + ) + .hasFieldEqualTo("dynamicOptimizationEnabled", true) + .hasFieldEqualTo("sampleRate", 37.0f) + .hasFieldEqualTo("customEndpointUrl", "https://session-replay.example.com") + .hasFieldEqualTo("imagePrivacy", ImagePrivacy.MASK_NONE) + .hasFieldEqualTo("touchPrivacy", TouchPrivacy.SHOW) + .hasFieldEqualTo("textAndInputPrivacy", TextAndInputPrivacy.MASK_ALL_INPUTS) + .hasFieldEqualTo("startRecordingImmediately", otherOptionsEnabled) + .hasFieldEqualTo("heatmapsEnabled", otherOptionsEnabled) + verify(mockPromise).resolve(null) + } + } +} diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm index 4cd9855ef..92fb93ecb 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm @@ -27,6 +27,7 @@ @implementation DdSessionReplay withStartRecordingImmediately:(BOOL)startRecordingImmediately withEnableHeatmaps:(BOOL)enableHeatmaps withEnableCompositionTreeRecording:(BOOL)enableCompositionTreeRecording + withAdaptiveCaptureSchedulingEnabled:(BOOL)adaptiveCaptureSchedulingEnabled withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) { @@ -38,6 +39,7 @@ @implementation DdSessionReplay startRecordingImmediately:startRecordingImmediately enableHeatmaps:enableHeatmaps enableCompositionTreeRecording:enableCompositionTreeRecording + adaptiveCaptureSchedulingEnabled:adaptiveCaptureSchedulingEnabled resolve:resolve reject:reject]; } @@ -81,8 +83,10 @@ - (void)enable:(double)replaySampleRate startRecordingImmediately:(BOOL)startRecordingImmediately enableHeatmaps:(BOOL)enableHeatmaps enableCompositionTreeRecording:(BOOL)enableCompositionTreeRecording + adaptiveCaptureSchedulingEnabled:(BOOL)adaptiveCaptureSchedulingEnabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + // Adaptive capture scheduling is Android-only; accept it to keep the shared bridge signature aligned. [self.ddSessionReplayImplementation enableWithReplaySampleRate:replaySampleRate customEndpoint:customEndpoint imagePrivacyLevel:imagePrivacyLevel diff --git a/packages/react-native-session-replay/src/SessionReplay.ts b/packages/react-native-session-replay/src/SessionReplay.ts index fab2a2d29..c7e30354c 100644 --- a/packages/react-native-session-replay/src/SessionReplay.ts +++ b/packages/react-native-session-replay/src/SessionReplay.ts @@ -112,6 +112,16 @@ export interface SessionReplayConfiguration { * Default: `false`. */ enableCompositionTreeRecording?: boolean; + + /** + * Enables experimental adaptive capture scheduling to reduce capture CPU usage + * on screens with repeated draws, such as a blinking text cursor. + * Captures run after drawing and retain the latest pending update while the + * capture time budget recovers. + * Android only. This option has no effect on iOS. + * Default: `false`. + */ + adaptiveCaptureSchedulingEnabled?: boolean; } type InternalBaseSessionReplayConfiguration = { @@ -120,6 +130,7 @@ type InternalBaseSessionReplayConfiguration = { startRecordingImmediately: boolean; enableHeatmaps: boolean; enableCompositionTreeRecording: boolean; + adaptiveCaptureSchedulingEnabled: boolean; }; type InternalPrivacySessionReplayConfiguration = { @@ -139,7 +150,8 @@ const DEFAULTS: InternalSessionReplayConfiguration = { textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_ALL, startRecordingImmediately: true, enableHeatmaps: false, - enableCompositionTreeRecording: false + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false }; export class SessionReplayWrapper { @@ -158,7 +170,8 @@ export class SessionReplayWrapper { customEndpoint, startRecordingImmediately, enableHeatmaps, - enableCompositionTreeRecording + enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled } = configuration; const baseConfig: InternalBaseSessionReplayConfiguration = { @@ -181,7 +194,10 @@ export class SessionReplayWrapper { enableCompositionTreeRecording: enableCompositionTreeRecording !== undefined ? enableCompositionTreeRecording - : DEFAULTS.enableCompositionTreeRecording + : DEFAULTS.enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled: + adaptiveCaptureSchedulingEnabled ?? + DEFAULTS.adaptiveCaptureSchedulingEnabled }; const privacyConfig: InternalPrivacySessionReplayConfiguration = { @@ -210,7 +226,8 @@ export class SessionReplayWrapper { textAndInputPrivacyLevel, startRecordingImmediately, enableHeatmaps, - enableCompositionTreeRecording + enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled } = this.buildConfiguration(configuration); return this.nativeSessionReplay.enable( @@ -221,7 +238,8 @@ export class SessionReplayWrapper { textAndInputPrivacyLevel, startRecordingImmediately, enableHeatmaps, - enableCompositionTreeRecording + enableCompositionTreeRecording, + adaptiveCaptureSchedulingEnabled ); }; 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 e88a86695..85f92a6f1 100644 --- a/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts +++ b/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts @@ -21,6 +21,30 @@ function getRandomEnumValue< return values[randomIndex]; // Return the random value } +function expectNativeEnableConfiguration(expected: { + replaySampleRate: number; + customEndpoint: string; + imagePrivacyLevel: string; + touchPrivacyLevel: string; + textAndInputPrivacyLevel: string; + startRecordingImmediately: boolean; + enableHeatmaps: boolean; + enableCompositionTreeRecording: boolean; + adaptiveCaptureSchedulingEnabled: boolean; +}): void { + expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( + expected.replaySampleRate, + expected.customEndpoint, + expected.imagePrivacyLevel, + expected.touchPrivacyLevel, + expected.textAndInputPrivacyLevel, + expected.startRecordingImmediately, + expected.enableHeatmaps, + expected.enableCompositionTreeRecording, + expected.adaptiveCaptureSchedulingEnabled + ); +} + beforeEach(() => { NativeModules.DdSessionReplay.enable.mockClear(); }); @@ -30,16 +54,17 @@ describe('SessionReplay', () => { it('calls native session replay with default configuration', () => { SessionReplay.enable(); - expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( - 100, - '', - 'MASK_ALL', - 'HIDE', - 'MASK_ALL', - true, - false, - false - ); + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: '', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false + }); }); it('calls native session replay with provided configuration { w custom endpoint }', () => { @@ -48,33 +73,112 @@ describe('SessionReplay', () => { customEndpoint: 'https://session-replay.example.com' }); - expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( - 100, - 'https://session-replay.example.com', - 'MASK_ALL', - 'HIDE', - 'MASK_ALL', - true, - false, - false - ); + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: 'https://session-replay.example.com', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false + }); }); + it.each([true, false])( + 'forwards adaptive capture scheduling configuration (%s) to native', + adaptiveCaptureSchedulingEnabled => { + SessionReplay.enable({ adaptiveCaptureSchedulingEnabled }); + + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: '', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled + }); + } + ); + + it.each([ + undefined, + {}, + { adaptiveCaptureSchedulingEnabled: undefined } + ])( + 'does not retain an opt-in when the next configuration omits it (%p)', + configuration => { + SessionReplay.enable({ + adaptiveCaptureSchedulingEnabled: true + }); + NativeModules.DdSessionReplay.enable.mockClear(); + + SessionReplay.enable(configuration); + + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: '', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false + }); + } + ); + + it.each([true, false])( + 'preserves other options with adaptive capture scheduling set to %s', + adaptiveCaptureSchedulingEnabled => { + SessionReplay.enable({ + replaySampleRate: 37, + customEndpoint: 'https://session-replay.example.com', + imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE, + touchPrivacyLevel: TouchPrivacyLevel.SHOW, + textAndInputPrivacyLevel: + TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS, + startRecordingImmediately: false, + enableHeatmaps: true, + enableCompositionTreeRecording: true, + adaptiveCaptureSchedulingEnabled + }); + + expectNativeEnableConfiguration({ + replaySampleRate: 37, + customEndpoint: 'https://session-replay.example.com', + imagePrivacyLevel: 'MASK_NONE', + touchPrivacyLevel: 'SHOW', + textAndInputPrivacyLevel: 'MASK_SENSITIVE_INPUTS', + startRecordingImmediately: false, + enableHeatmaps: true, + enableCompositionTreeRecording: true, + adaptiveCaptureSchedulingEnabled + }); + } + ); + it('forwards composition tree recording configuration to native', () => { SessionReplay.enable({ enableCompositionTreeRecording: true }); - expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( - 100, - '', - 'MASK_ALL', - 'HIDE', - 'MASK_ALL', - true, - false, - true - ); + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: '', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: true, + adaptiveCaptureSchedulingEnabled: false + }); }); it('calls native session replay with provided configuration { w random privacy levels }', () => { @@ -93,18 +197,17 @@ describe('SessionReplay', () => { textAndInputPrivacyLevel: textAndInput }); - expect( - NativeModules.DdSessionReplay.enable - ).toHaveBeenCalledWith( - 100, - 'https://session-replay.example.com', - image, - touch, - textAndInput, - true, - false, - false - ); + expectNativeEnableConfiguration({ + replaySampleRate: 100, + customEndpoint: 'https://session-replay.example.com', + imagePrivacyLevel: image, + touchPrivacyLevel: touch, + textAndInputPrivacyLevel: textAndInput, + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false + }); } }); @@ -114,16 +217,17 @@ describe('SessionReplay', () => { customEndpoint: '' }); - expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( - 0, - '', - 'MASK_ALL', - 'HIDE', - 'MASK_ALL', - true, - false, - false - ); + expectNativeEnableConfiguration({ + replaySampleRate: 0, + customEndpoint: '', + imagePrivacyLevel: 'MASK_ALL', + touchPrivacyLevel: 'HIDE', + textAndInputPrivacyLevel: 'MASK_ALL', + startRecordingImmediately: true, + enableHeatmaps: false, + enableCompositionTreeRecording: false, + adaptiveCaptureSchedulingEnabled: false + }); }); }); }); diff --git a/packages/react-native-session-replay/src/nativeModulesTypes.ts b/packages/react-native-session-replay/src/nativeModulesTypes.ts index 07005da8d..3c00ed6a7 100644 --- a/packages/react-native-session-replay/src/nativeModulesTypes.ts +++ b/packages/react-native-session-replay/src/nativeModulesTypes.ts @@ -34,6 +34,7 @@ export interface NativeSessionReplayType extends NativeDdSessionReplay { * to be started manually. Default: `true`. * @param enableHeatmaps: Whether heatmap identifier computation is enabled. * @param enableCompositionTreeRecording: Whether the experimental Composition Tree recording pipeline is enabled. + * @param adaptiveCaptureSchedulingEnabled: Whether experimental adaptive capture scheduling is enabled (Android only). */ enable( replaySampleRate: number, @@ -43,7 +44,8 @@ export interface NativeSessionReplayType extends NativeDdSessionReplay { textAndInputPrivacyLevel: TextAndInputPrivacyLevel, startRecordingImmediately: boolean, enableHeatmaps: boolean, - enableCompositionTreeRecording: boolean + enableCompositionTreeRecording: boolean, + adaptiveCaptureSchedulingEnabled: 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 22bc3e1c0..8f5caa528 100644 --- a/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts +++ b/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts @@ -26,6 +26,7 @@ export interface Spec extends TurboModule { * and the recording will need to be started manually. Default: `true`. * @param enableHeatmaps: Whether heatmap identifier computation is enabled. * @param enableCompositionTreeRecording: Whether the experimental Composition Tree recording pipeline is enabled. + * @param adaptiveCaptureSchedulingEnabled: Whether experimental adaptive capture scheduling is enabled (Android only). */ enable( replaySampleRate: number, @@ -35,7 +36,8 @@ export interface Spec extends TurboModule { textAndInputPrivacyLevel: string, startRecordingImmediately: boolean, enableHeatmaps: boolean, - enableCompositionTreeRecording: boolean + enableCompositionTreeRecording: boolean, + adaptiveCaptureSchedulingEnabled: boolean ): Promise; /** diff --git a/packages/react-native-webview/android/build.gradle b/packages/react-native-webview/android/build.gradle index aa736dfe2..65cacb2d1 100644 --- a/packages/react-native-webview/android/build.gradle +++ b/packages/react-native-webview/android/build.gradle @@ -205,7 +205,7 @@ dependencies { implementation "com.facebook.react:react-android:$reactNativeVersion" } - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native')