From 37afc0dca3c8f7bfae847af2db6d3f1d34f4e6bf Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Fri, 8 Nov 2024 17:09:15 +0100 Subject: [PATCH] Session Replay Start and Stop API --- .../DdSessionReplayImplementation.kt | 30 +++++++++++- .../sessionreplay/SessionReplaySDKWrapper.kt | 14 ++++++ .../sessionreplay/SessionReplayWrapper.kt | 10 ++++ .../sessionreplay/DdSessionReplay.kt | 26 +++++++++- .../DdSessionReplayImplementationTest.kt | 47 +++++++++++++++---- .../ios/Sources/DdSessionReplay.mm | 40 ++++++++++++++-- .../DdSessionReplayImplementation.swift | 43 ++++++++++++++++- .../src/SessionReplay.ts | 40 ++++++++++++++-- .../src/__tests__/SessionReplay.test.ts | 20 ++++++-- .../src/nativeModulesTypes.ts | 14 +++++- .../src/specs/NativeDdSessionReplay.ts | 14 +++++- 11 files changed, 273 insertions(+), 25 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 bf16e6581..179425b8f 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 @@ -30,12 +30,20 @@ class DdSessionReplayImplementation( * @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 immediately. */ - fun enable(replaySampleRate: Double, defaultPrivacyLevel: String, customEndpoint: String, promise: Promise) { + fun enable( + replaySampleRate: Double, + defaultPrivacyLevel: String, + customEndpoint: 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) .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(reactContext, logger)) if (customEndpoint != "") { @@ -46,6 +54,26 @@ class DdSessionReplayImplementation( promise.resolve(null) } + /** + * Manually start recording the current session. + */ + fun startRecording(promise: Promise) { + sessionReplayProvider().startRecording( + DatadogSDKWrapperStorage.getSdkCore() as FeatureSdkCore + ) + promise.resolve(null) + } + + /** + * Manually stop recording the current session. + */ + fun stopRecording(promise: Promise) { + sessionReplayProvider().stopRecording( + DatadogSDKWrapperStorage.getSdkCore() as FeatureSdkCore + ) + promise.resolve(null) + } + @Deprecated("Privacy should be set with separate properties mapped to " + "`setImagePrivacy`, `setTouchPrivacy`, `setTextAndInputPrivacy`, but they are" + " currently unavailable.") 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 index b20af0bda..d845b6d47 100644 --- 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 @@ -24,4 +24,18 @@ internal class SessionReplaySDKWrapper : SessionReplayWrapper { sdkCore, ) } + + /** + * Manually start recording the current session. + */ + override fun startRecording(sdkCore: SdkCore) { + SessionReplay.startRecording(sdkCore) + } + + /** + * Manually stop recording the current session. + */ + override fun stopRecording(sdkCore: SdkCore) { + SessionReplay.stopRecording(sdkCore) + } } 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 index 2356a1ffb..b523834f4 100644 --- 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 @@ -21,4 +21,14 @@ interface SessionReplayWrapper { sessionReplayConfiguration: SessionReplayConfiguration, sdkCore: SdkCore ) + + /** + * Manually start recording the current session. + */ + fun startRecording(sdkCore: SdkCore) + + /** + * Manually stop recording the current session. + */ + fun stopRecording(sdkCore: SdkCore) } 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 90f5e0289..9490da50f 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 @@ -27,14 +27,38 @@ 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 startRecordingImmediately Whether the recording should start immediately. */ @ReactMethod fun enable( replaySampleRate: Double, defaultPrivacyLevel: String, customEndpoint: String, + startRecordingImmediately: Boolean, promise: Promise ) { - implementation.enable(replaySampleRate, defaultPrivacyLevel, customEndpoint, promise) + implementation.enable( + replaySampleRate, + defaultPrivacyLevel, + customEndpoint, + startRecordingImmediately, + promise + ) + } + + /** + * Manually start recording the current session. + */ + @ReactMethod + fun startRecording(promise: Promise) { + implementation.startRecording(promise) + } + + /** + * Manually stop recording the current session. + */ + @ReactMethod + fun stopRecording(promise: Promise) { + implementation.stopRecording(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 b4d54a130..1be9191dc 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 @@ -16,6 +16,7 @@ import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerModule +import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -72,31 +73,50 @@ internal class DdSessionReplayImplementationTest { @Test fun `M enable session replay W privacy = ALLOW`( @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, - @StringForgery(regex = ".+") customEndpoint: String + @StringForgery(regex = ".+") customEndpoint: String, + @BoolForgery startRecordingImmediately: Boolean ) { - testSessionReplayEnable("ALLOW", replaySampleRate, customEndpoint) + testSessionReplayEnable( + "ALLOW", + replaySampleRate, + customEndpoint, + startRecordingImmediately + ) } @Test fun `M enable session replay W privacy = MASK`( @DoubleForgery(min = 0.0, max = 100.0) replaySampleRate: Double, - @StringForgery(regex = ".+") customEndpoint: String + @StringForgery(regex = ".+") customEndpoint: String, + @BoolForgery startRecordingImmediately: Boolean ) { - testSessionReplayEnable("MASK", replaySampleRate, customEndpoint) + 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 + @StringForgery(regex = ".+") customEndpoint: String, + @BoolForgery startRecordingImmediately: Boolean ) { - testSessionReplayEnable("MASK_USER_INPUT", replaySampleRate, customEndpoint) + testSessionReplayEnable( + "MASK_USER_INPUT", + replaySampleRate, + customEndpoint, + startRecordingImmediately + ) } private fun testSessionReplayEnable( privacy: String, replaySampleRate: Double, - customEndpoint: String + customEndpoint: String, + startRecordingImmediately: Boolean ) { // Given val sessionReplayConfigCaptor = argumentCaptor() @@ -106,6 +126,7 @@ internal class DdSessionReplayImplementationTest { replaySampleRate, privacy, customEndpoint, + startRecordingImmediately, mockPromise ) @@ -144,19 +165,27 @@ internal class DdSessionReplayImplementationTest { 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 + @StringForgery(regex = "^/(?!ALLOW|MASK_USER_INPUT)([a-z0-9]+)$/i") privacy: String, + @BoolForgery startRecordingImmediately: Boolean ) { // Given val sessionReplayConfigCaptor = argumentCaptor() // When - testedSessionReplay.enable(replaySampleRate, privacy, "", mockPromise) + testedSessionReplay.enable( + replaySampleRate, + privacy, + "", + startRecordingImmediately, + mockPromise + ) // Then verify(mockSessionReplay).enable(sessionReplayConfigCaptor.capture(), any()) assertThat(sessionReplayConfigCaptor.firstValue) .hasFieldEqualTo("sampleRate", replaySampleRate.toFloat()) .hasFieldEqualTo("privacy", SessionReplayPrivacy.MASK) + .hasFieldEqualTo("startRecordingImmediately", startRecordingImmediately) .doesNotHaveField("customEndpointUrl") } } diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm index e2cc08ad3..1b33e79f1 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplay.mm @@ -20,10 +20,26 @@ @implementation DdSessionReplay RCT_REMAP_METHOD(enable, withEnableReplaySampleRate:(double)replaySampleRate withDefaultPrivacyLevel:(NSString*)defaultPrivacyLevel withCustomEndpoint:(NSString*)customEndpoint + withStartRecordingImmediately:(BOOL)startRecordingImmediately withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) { - [self enable:replaySampleRate defaultPrivacyLevel:defaultPrivacyLevel customEndpoint:customEndpoint resolve:resolve reject:reject]; + [self enable:replaySampleRate + defaultPrivacyLevel:defaultPrivacyLevel + customEndpoint:customEndpoint + startRecordingImmediately:startRecordingImmediately + resolve:resolve + reject:reject]; +} + +RCT_EXPORT_METHOD(startRecording:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self startRecordingWithResolver:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(stopRecording:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self stopRecordingWithResolver:resolve reject:reject]; } // Thanks to this guard, we won't compile this code when we build for the old architecture. @@ -47,8 +63,26 @@ + (BOOL)requiresMainQueueSetup { return NO; } -- (void)enable:(double)replaySampleRate defaultPrivacyLevel:(NSString *)defaultPrivacyLevel customEndpoint:(NSString*)customEndpoint resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [self.ddSessionReplayImplementation enableWithReplaySampleRate:replaySampleRate defaultPrivacyLevel:defaultPrivacyLevel customEndpoint:customEndpoint resolve:resolve reject:reject]; +- (void)enable:(double)replaySampleRate + defaultPrivacyLevel:(NSString *)defaultPrivacyLevel + customEndpoint:(NSString*)customEndpoint + startRecordingImmediately:(BOOL)startRecordingImmediately + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [self.ddSessionReplayImplementation enableWithReplaySampleRate:replaySampleRate + defaultPrivacyLevel:defaultPrivacyLevel + customEndpoint:customEndpoint + startRecordingImmediately:startRecordingImmediately + resolve:resolve + reject:reject]; +} + +- (void)startRecordingWithResolver:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSessionReplayImplementation startRecordingWithResolve:resolve reject:reject]; +} + +- (void)stopRecordingWithResolver:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSessionReplayImplementation stopRecordingWithResolve:resolve reject:reject]; } @end diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index 40136b4ac..6166d8866 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -30,7 +30,14 @@ public class DdSessionReplayImplementation: NSObject { } @objc - public func enable(replaySampleRate: Double, defaultPrivacyLevel: String, customEndpoint: String, resolve:RCTPromiseResolveBlock, reject:RCTPromiseRejectBlock) -> Void { + public func enable( + replaySampleRate: Double, + defaultPrivacyLevel: String, + customEndpoint: String, + startRecordingImmediately: Bool, + resolve:RCTPromiseResolveBlock, + reject:RCTPromiseRejectBlock + ) -> Void { var customEndpointURL: URL? = nil if (customEndpoint != "") { customEndpointURL = URL(string: "\(customEndpoint)/api/v2/replay" as String) @@ -38,6 +45,7 @@ public class DdSessionReplayImplementation: NSObject { var sessionReplayConfiguration = SessionReplay.Configuration( replaySampleRate: Float(replaySampleRate), defaultPrivacyLevel: buildPrivacyLevel(privacyLevel: defaultPrivacyLevel as NSString), + startRecordingImmediately: startRecordingImmediately, customEndpoint: customEndpointURL ) @@ -55,6 +63,28 @@ public class DdSessionReplayImplementation: NSObject { resolve(nil) } + @objc + public func startRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void { + if let core = DatadogSDKWrapper.shared.getCoreInstance() { + sessionReplay.startRecording(in: core) + } else { + consolePrint("Core instance was not found when calling startRecording in Session Replay.", .critical) + } + + resolve(nil) + } + + @objc + public func stopRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void { + if let core = DatadogSDKWrapper.shared.getCoreInstance() { + sessionReplay.stopRecording(in: core) + } else { + consolePrint("Core instance was not found when calling stopRecording in Session Replay.", .critical) + } + + resolve(nil) + } + func buildPrivacyLevel(privacyLevel: NSString) -> SessionReplayPrivacyLevel { switch privacyLevel.lowercased { case "mask": @@ -74,10 +104,21 @@ internal protocol SessionReplayProtocol { with configuration: SessionReplay.Configuration, in core: DatadogCoreProtocol ) + + func startRecording(in core: DatadogCoreProtocol) + func stopRecording(in core: DatadogCoreProtocol) } internal class NativeSessionReplay: SessionReplayProtocol { func enable(with configuration: DatadogSessionReplay.SessionReplay.Configuration, in core: DatadogCoreProtocol) { SessionReplay.enable(with: configuration, in: core) } + + func startRecording(in core: any DatadogInternal.DatadogCoreProtocol) { + SessionReplay.startRecording(in: core) + } + + func stopRecording(in core: any DatadogInternal.DatadogCoreProtocol) { + SessionReplay.stopRecording(in: core) + } } diff --git a/packages/react-native-session-replay/src/SessionReplay.ts b/packages/react-native-session-replay/src/SessionReplay.ts index bc1dffe33..cfdf8de96 100644 --- a/packages/react-native-session-replay/src/SessionReplay.ts +++ b/packages/react-native-session-replay/src/SessionReplay.ts @@ -34,12 +34,20 @@ export interface SessionReplayConfiguration { * 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. + * when `false` it doesn't, and the recording will need to be started manually. + * Default: `true`. + */ + startRecordingImmediately?: boolean; } const DEFAULTS = { replaySampleRate: 0, defaultPrivacyLevel: SessionReplayPrivacy.MASK, - customEndpoint: '' + customEndpoint: '', + startRecordingImmediately: true }; export class SessionReplayWrapper { @@ -53,6 +61,7 @@ export class SessionReplayWrapper { replaySampleRate: number; defaultPrivacyLevel: SessionReplayPrivacy; customEndpoint: string; + startRecordingImmediately: boolean; } => { if (!configuration) { return DEFAULTS; @@ -60,7 +69,8 @@ export class SessionReplayWrapper { const { replaySampleRate, defaultPrivacyLevel, - customEndpoint + customEndpoint, + startRecordingImmediately } = configuration; return { replaySampleRate: @@ -74,7 +84,11 @@ export class SessionReplayWrapper { customEndpoint: customEndpoint !== undefined ? customEndpoint - : DEFAULTS.customEndpoint + : DEFAULTS.customEndpoint, + startRecordingImmediately: + startRecordingImmediately !== undefined + ? startRecordingImmediately + : DEFAULTS.startRecordingImmediately }; }; @@ -86,15 +100,31 @@ export class SessionReplayWrapper { const { replaySampleRate, defaultPrivacyLevel, - customEndpoint + customEndpoint, + startRecordingImmediately } = this.buildConfiguration(configuration); return this.nativeSessionReplay.enable( replaySampleRate, defaultPrivacyLevel, - customEndpoint + customEndpoint, + startRecordingImmediately ); }; + + /** + * Manually start the recording of the current session. + */ + startRecording = (): Promise => { + return this.nativeSessionReplay.startRecording(); + }; + + /** + * Manually stop the recording of the current session. + */ + stopRecording = (): Promise => { + return this.nativeSessionReplay.stopRecording(); + }; } export const SessionReplay = new SessionReplayWrapper(); 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 1b32ff27c..a1ed2e01b 100644 --- a/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts +++ b/packages/react-native-session-replay/src/__tests__/SessionReplay.test.ts @@ -20,7 +20,8 @@ describe('SessionReplay', () => { expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 0, 'MASK', - '' + '', + true ); }); @@ -34,7 +35,8 @@ describe('SessionReplay', () => { expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 100, 'ALLOW', - 'https://session-replay.example.com' + 'https://session-replay.example.com', + true ); }); @@ -47,7 +49,19 @@ describe('SessionReplay', () => { expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( 0, 'MASK', - '' + '', + true + ); + }); + + it('calls native session replay with start immediately = false', () => { + SessionReplay.enable({ startRecordingImmediately: false }); + + expect(NativeModules.DdSessionReplay.enable).toHaveBeenCalledWith( + 0, + 'MASK', + '', + false ); }); }); diff --git a/packages/react-native-session-replay/src/nativeModulesTypes.ts b/packages/react-native-session-replay/src/nativeModulesTypes.ts index da4165b32..00f90202e 100644 --- a/packages/react-native-session-replay/src/nativeModulesTypes.ts +++ b/packages/react-native-session-replay/src/nativeModulesTypes.ts @@ -22,10 +22,22 @@ export interface NativeSessionReplayType extends NativeDdSessionReplay { * @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`. */ enable( replaySampleRate: number, defaultPrivacyLevel: PrivacyLevel, - customEndpoint: string + customEndpoint: string, + startRecordingImmediately: boolean ): Promise; + + /** + * Manually start the recording of the current session. + */ + startRecording(): Promise; + + /** + * Manually stop the recording of the current session. + */ + stopRecording(): Promise; } diff --git a/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts b/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts index 60a62c8b4..272ebcf77 100644 --- a/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts +++ b/packages/react-native-session-replay/src/specs/NativeDdSessionReplay.ts @@ -19,12 +19,24 @@ export interface Spec extends TurboModule { * @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`. */ enable( replaySampleRate: number, defaultPrivacyLevel: string, - customEndpoint: string + customEndpoint: string, + startRecordingImmediately: boolean ): Promise; + + /** + * Manually start the recording of the current session. + */ + startRecording(): Promise; + + /** + * Manually stop the recording of the current session. + */ + stopRecording(): Promise; } // eslint-disable-next-line func-names