-
Notifications
You must be signed in to change notification settings - Fork 4
feat: [SDK-2121] implement session replay for react native android #456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
e93d5ea
feat: implement session replay for react native android
beekld ccbbb39
Merge branch 'main' into beeklimt/SDK-2121
beekld 7ba6e77
fix: lint
beekld e1ac9be
refactor start/stop so that it doesnt need a state machine
beekld 5c7b6e7
add afterIdentify hook that propagates the context
beekld c996e31
simplify comments
beekld 281b2ab
dont call a callback while holding a lock
beekld 69a4758
Merge branch 'main' into beeklimt/SDK-2121
beekld 7d5c3f4
undo the identify hook
beekld 294a5d5
rename some local variables to be clearer
beekld 48df947
add defensive try-catch around LDReplay.start/stop
beekld a6fc982
remove obsolete tests
beekld d9a9964
Merge branch 'main' into beeklimt/SDK-2121
beekld File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
...n-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| package com.sessionreplayreactnative | ||
|
|
||
| import android.app.Application | ||
| import android.os.Handler | ||
| import android.os.Looper | ||
| import com.facebook.react.bridge.ReadableMap | ||
| import com.launchdarkly.logging.LDLogger | ||
| import com.launchdarkly.observability.api.ObservabilityOptions | ||
| import com.launchdarkly.observability.plugin.Observability | ||
| import com.launchdarkly.observability.replay.PrivacyProfile | ||
| import com.launchdarkly.observability.replay.ReplayOptions | ||
| import com.launchdarkly.observability.replay.plugin.SessionReplay | ||
| import com.launchdarkly.observability.sdk.LDReplay | ||
| import com.launchdarkly.sdk.ContextKind | ||
| import com.launchdarkly.sdk.LDContext | ||
| import com.launchdarkly.sdk.android.Components | ||
| import com.launchdarkly.sdk.android.LDAndroidLogging | ||
| import com.launchdarkly.sdk.android.LDClient | ||
| import com.launchdarkly.sdk.android.LDConfig | ||
|
|
||
| internal class SessionReplayClientAdapter private constructor() { | ||
|
|
||
| private val lock = Any() | ||
| private var mobileKey: String? = null | ||
| private var serviceName: String = DEFAULT_SERVICE_NAME | ||
| private var replayOptions: ReplayOptions? = null | ||
| // Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks). | ||
| private var initialized = false | ||
| private val logger = LDLogger.withAdapter(LDAndroidLogging.adapter(), TAG) | ||
|
|
||
| fun setMobileKey(mobileKey: String, options: ReadableMap?) { | ||
| synchronized(lock) { | ||
| this.mobileKey = mobileKey | ||
| this.serviceName = options?.takeIf { it.hasKey("serviceName") } | ||
| ?.getString("serviceName") | ||
| ?.takeIf { it.isNotBlank() } | ||
| ?: DEFAULT_SERVICE_NAME | ||
| this.replayOptions = replayOptionsFrom(options) | ||
| } | ||
| } | ||
|
|
||
| fun start(application: Application, completion: (Boolean, String?) -> Unit) { | ||
| val localMobileKey: String? | ||
| val localServiceName: String | ||
| val localReplayOptions: ReplayOptions? | ||
|
|
||
| // Capture configuration under the lock, then release it before posting to the main thread. | ||
| synchronized(lock) { | ||
| localMobileKey = mobileKey | ||
| localReplayOptions = replayOptions | ||
| localServiceName = serviceName | ||
| } | ||
| if (localMobileKey == null || localReplayOptions == null) { | ||
| val msg = "start: configure() was not called — mobile key or options are missing" | ||
| logger.error(msg) | ||
| completion(false, msg) | ||
| return | ||
| } | ||
|
|
||
| // All work runs on the main thread so that: | ||
| // 1. initLDClient() satisfies the main-thread requirement of OpenTelemetryRum.build(). | ||
| // 2. Consecutive start()/stop() calls are naturally serialized without locks. | ||
| Handler(Looper.getMainLooper()).post { | ||
| if (!initialized) { | ||
| try { | ||
| initLDClient(application, localMobileKey, localServiceName, localReplayOptions) | ||
| } catch (e: Exception) { | ||
| logger.error("start: LDClient.init() threw {0}: {1}", e::class.simpleName, e.message) | ||
| completion(false, "Session replay failed to initialize.") | ||
| return@post | ||
| } | ||
| initialized = true | ||
| } else { | ||
| logger.debug("start: already initialized, re-applying isEnabled={0}", localReplayOptions.enabled) | ||
| } | ||
| try { | ||
| applyEnabled(localReplayOptions.enabled) | ||
| } catch (e: Exception) { | ||
| logger.error("start: applyEnabled threw {0}: {1}", e::class.simpleName, e.message) | ||
| completion(false, "Session replay failed to start.") | ||
| return@post | ||
| } | ||
| completion(true, null) | ||
| } | ||
| } | ||
|
|
||
| fun stop(completion: () -> Unit) { | ||
| logger.debug("stop") | ||
| // Post to the main thread so that stop() queues behind any in-progress start(). | ||
| Handler(Looper.getMainLooper()).post { | ||
| try { | ||
| LDReplay.stop() | ||
| } catch (e: Exception) { | ||
| logger.error("stop: threw {0}: {1}", e::class.simpleName, e.message) | ||
| } | ||
| completion() | ||
| } | ||
| } | ||
|
|
||
| private fun initLDClient(application: Application, mobileKey: String, serviceName: String, replayOptions: ReplayOptions) { | ||
| logger.debug("initLDClient: calling LDClient.init()") | ||
| val config = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) | ||
| .mobileKey(mobileKey) | ||
| .offline(true) | ||
| .plugins( | ||
| Components.plugins().setPlugins( | ||
| listOf( | ||
| // TODO: Pass JS ObservabilityOptions such as backendUrl, | ||
| // resourceAttributes, and sessionBackgroundTimeout through to here. | ||
| Observability( | ||
| application = application, | ||
| mobileKey = mobileKey, | ||
| options = ObservabilityOptions( | ||
| serviceName = serviceName, | ||
| logAdapter = LDAndroidLogging.adapter(), | ||
| ) | ||
| ), | ||
| SessionReplay(options = replayOptions), | ||
| ) | ||
| ) | ||
| ) | ||
| .build() | ||
|
|
||
| // The context key is a placeholder. The LDClient is offline and never sends it to | ||
| // LaunchDarkly servers, but SessionReplay does use it locally to attribute sessions. | ||
| // | ||
| // TODO: Pass the actual initial context here once the LaunchDarkly React Native SDK | ||
| // supports providing a context at initialization time. Currently, context is only | ||
| // available after an explicit client.identify() call — getContext() always returns | ||
| // undefined when register() runs during the LDClient constructor. | ||
| val placeholderContext = LDContext.builder(ContextKind.DEFAULT, "placeholder").build() | ||
| // timeout=0: return immediately without blocking the main thread waiting for flags. | ||
| // onPluginsReady() fires synchronously during init() before it returns. | ||
| LDClient.init(application, config, placeholderContext, 0) | ||
| } | ||
|
|
||
| private fun applyEnabled(enabled: Boolean) { | ||
| if (enabled) { | ||
| LDReplay.start() | ||
| } else { | ||
| LDReplay.stop() | ||
| } | ||
| } | ||
|
|
||
| internal fun replayOptionsFrom(map: ReadableMap?): ReplayOptions { | ||
| if (map == null) { | ||
| return ReplayOptions( | ||
| enabled = true, | ||
| privacyProfile = PrivacyProfile(maskTextInputs = true) | ||
| ) | ||
| } | ||
|
|
||
| val isEnabled = if (map.hasKey("isEnabled")) map.getBoolean("isEnabled") else true | ||
| val maskTextInputs = if (map.hasKey("maskTextInputs")) map.getBoolean("maskTextInputs") else true | ||
| val maskWebViews = if (map.hasKey("maskWebViews")) map.getBoolean("maskWebViews") else false | ||
| val maskText = if (map.hasKey("maskLabels")) map.getBoolean("maskLabels") else false | ||
| val maskImages = if (map.hasKey("maskImages")) map.getBoolean("maskImages") else false | ||
|
|
||
| return ReplayOptions( | ||
| enabled = isEnabled, | ||
| privacyProfile = PrivacyProfile( | ||
| maskTextInputs = maskTextInputs, | ||
| maskWebViews = maskWebViews, | ||
| maskText = maskText, | ||
| maskImageViews = maskImages, | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| companion object { | ||
| val shared = SessionReplayClientAdapter() | ||
| private const val TAG = "LDSessionReplay" | ||
| private const val DEFAULT_SERVICE_NAME = "sessionreplay-react-native" | ||
| } | ||
| } | ||
63 changes: 39 additions & 24 deletions
63
...play/android/src/main/java/com/sessionreplayreactnative/SessionReplayReactNativeModule.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
...play/android/src/test/java/com/sessionreplayreactnative/SessionReplayClientAdapterTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package com.sessionreplayreactnative | ||
|
|
||
| import android.app.Application | ||
| import com.facebook.react.bridge.ReadableMap | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import org.junit.jupiter.api.Assertions.assertEquals | ||
| import org.junit.jupiter.api.Assertions.assertFalse | ||
| import org.junit.jupiter.api.Assertions.assertTrue | ||
| import org.junit.jupiter.api.Test | ||
|
|
||
| class SessionReplayClientAdapterTest { | ||
|
|
||
| private fun newAdapter(): SessionReplayClientAdapter { | ||
| val constructor = SessionReplayClientAdapter::class.java.getDeclaredConstructor() | ||
| constructor.isAccessible = true | ||
| return constructor.newInstance() | ||
| } | ||
|
|
||
| @Test | ||
| fun `replayOptionsFrom null map returns defaults`() { | ||
| val adapter = newAdapter() | ||
| val options = adapter.replayOptionsFrom(null) | ||
|
|
||
| assertTrue(options.enabled) | ||
| assertTrue(options.privacyProfile.maskTextInputs) | ||
| assertFalse(options.privacyProfile.maskWebViews) | ||
| assertFalse(options.privacyProfile.maskText) | ||
| assertFalse(options.privacyProfile.maskImageViews) | ||
| } | ||
|
|
||
| @Test | ||
| fun `replayOptionsFrom maps maskLabels key to maskText field`() { | ||
| val adapter = newAdapter() | ||
| val map = mockk<ReadableMap> { | ||
| every { hasKey("maskLabels") } returns true | ||
| every { getBoolean("maskLabels") } returns true | ||
| every { hasKey("isEnabled") } returns false | ||
| every { hasKey("maskTextInputs") } returns false | ||
| every { hasKey("maskWebViews") } returns false | ||
| every { hasKey("maskImages") } returns false | ||
| } | ||
|
|
||
| val options = adapter.replayOptionsFrom(map) | ||
|
|
||
| assertTrue(options.privacyProfile.maskText) | ||
| } | ||
|
|
||
| @Test | ||
| fun `start before setMobileKey calls completion with failure`() { | ||
| val adapter = newAdapter() | ||
| var success: Boolean? = null | ||
| var errorMessage: String? = null | ||
|
|
||
| adapter.start(mockk<Application>(relaxed = true)) { s, e -> | ||
| success = s | ||
| errorMessage = e | ||
| } | ||
|
|
||
| assertEquals(false, success) | ||
| assertTrue(errorMessage!!.contains("mobile key")) | ||
| } | ||
| } |
39 changes: 38 additions & 1 deletion
39
sdk/@launchdarkly/react-native-ld-session-replay/src/__tests__/index.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,38 @@ | ||
| it.todo('write a test'); | ||
| import NativeSessionReplayReactNative from '../NativeSessionReplayReactNative'; | ||
| import { configureSessionReplay, createSessionReplayPlugin } from '../index'; | ||
|
|
||
| jest.mock('../NativeSessionReplayReactNative', () => ({ | ||
| configure: jest.fn().mockResolvedValue(undefined), | ||
| startSessionReplay: jest.fn().mockResolvedValue(undefined), | ||
| stopSessionReplay: jest.fn().mockResolvedValue(undefined), | ||
| })); | ||
|
|
||
| describe('configureSessionReplay', () => { | ||
| it('rejects if key is empty', async () => { | ||
| await expect(configureSessionReplay('')).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it('rejects if key is whitespace', async () => { | ||
| await expect(configureSessionReplay(' ')).rejects.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('SessionReplayPluginAdapter', () => { | ||
| it('calls configure and startSessionReplay on register', async () => { | ||
| const plugin = createSessionReplayPlugin(); | ||
| plugin.register( | ||
| {}, | ||
| { sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' } | ||
| ); | ||
|
|
||
| await new Promise(process.nextTick); | ||
|
|
||
| expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith( | ||
| 'mob-key-123', | ||
| {} | ||
| ); | ||
| expect( | ||
| NativeSessionReplayReactNative.startSessionReplay | ||
| ).toHaveBeenCalled(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.