Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ class DdSdkImplementation(
fun initialize(configuration: ReadableMap, promise: Promise) {
val ddSdkConfiguration = configuration.asDdSdkConfiguration()

// On new arch DdSdk is a lazy TurboModule — it's instantiated on the first JS-side
// method call (typically this initialize()), which usually lands AFTER the activity's
// first onHostResume. That means registerLifecycleEvents's listener missed the first
// resume, reactContext is still null on the session listener, and the replay inside
Comment thread
marco-saia-datadog marked this conversation as resolved.
// nativeInitialization.initialize → onRnSdkInitialized has no emitter target. Setting
// the reactContext here — we are provably being dispatched through an active one —
// closes that race so the cached session ID is delivered on the first JS init.
DdSdkSessionStartedListener.getInstance().setReactContext(reactContext)

val nativeInitialization = DdSdkNativeInitialization(appContext, datadog)
nativeInitialization.initialize(ddSdkConfiguration)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class DdSdkNativeInitialization internal constructor(
private val datadog: DatadogWrapper = DatadogSDKWrapper(),
private val jsonFileReader: JSONFileReader = JSONFileReader()
) {
internal fun initialize(ddSdkConfiguration: DdSdkConfiguration) {
internal fun initialize(ddSdkConfiguration: DdSdkConfiguration, isCalledFromJs: Boolean = true) {
val sdkConfiguration = buildSdkConfiguration(ddSdkConfiguration)
val rumConfiguration = buildRumConfiguration(ddSdkConfiguration)
val logsConfiguration = buildLogsConfiguration(ddSdkConfiguration)
Expand All @@ -49,10 +49,14 @@ class DdSdkNativeInitialization internal constructor(
configureSdkVerbosity(ddSdkConfiguration)
configureRumAndTracesForLogs(ddSdkConfiguration)

if (datadog.isInitialized()) {
datadog.getRumMonitor().getCurrentSessionId {
it?.let { sessionId ->
DdSdkSessionStartedListener.getInstance().onSessionStarted(sessionId, false)
if (isCalledFromJs) {
DdSdkSessionStartedListener.getInstance().onRnSdkInitialized()
// Handles the case in which the SDK was already initialized with initFromNative.
if (datadog.isInitialized()) {
datadog.getRumMonitor().getCurrentSessionId {
it?.let { sessionId ->
DdSdkSessionStartedListener.getInstance().onSessionStarted(sessionId, false)
}
}
}
}
Expand Down Expand Up @@ -376,7 +380,10 @@ class DdSdkNativeInitialization internal constructor(
fun initFromNative(appContext: Context) {
val nativeInitialization = DdSdkNativeInitialization(appContext.applicationContext)
try {
nativeInitialization.initialize(nativeInitialization.getConfigurationFromJSONFile())
nativeInitialization.initialize(
ddSdkConfiguration = nativeInitialization.getConfigurationFromJSONFile(),
isCalledFromJs = false
)
} catch (@Suppress("TooGenericExceptionCaught") error: Exception) {
Log.w(
DdSdkNativeInitialization::class.java.canonicalName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,95 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
import org.jetbrains.annotations.TestOnly


internal class DdSdkSessionStartedListener private constructor(): RumSessionListener {
internal class DdSdkSessionStartedListener private constructor() : RumSessionListener {
companion object {
// JS-side callable module registered via BatchedBridge.registerCallableModule.
private const val BRIDGE_MODULE_NAME = "DatadogInternalReactBridge"
private const val BRIDGE_MODULE_METHOD = "__datadogOnMessageReceived"

private var instance: DdSdkSessionStartedListener? = null

// Process-level state — tracks whether JS initialize() has ever run since the
// app process started. Kept outside the instance on purpose: the listener
// singleton is recreated on every onHostPause/onHostDestroy via invalidate(),
// but the JS bridge module registration survives activity lifecycle events, so
// this flag must survive them too.
Comment thread
marco-saia-datadog marked this conversation as resolved.
Comment thread
marco-saia-datadog marked this conversation as resolved.
private var isRnSdkInitialized: Boolean = false

// Routes UI-thread dispatch through an injectable abstraction. Tests inject
// TestUiThreadExecutor (synchronous, no main looper required) instead of the
// real ReactUiThreadExecutor that wraps UiThreadUtil.
private var uiThreadExecutor: UiThreadExecutor = ReactUiThreadExecutor()

// Returns the shared listener instance, creating it on first call.
fun getInstance(): DdSdkSessionStartedListener {
if (instance == null) {
instance = DdSdkSessionStartedListener()
}
return instance!!
}

// Resets the singleton — used in tests and on host pause/destroy to release the
// React context reference. Does NOT reset isRnSdkInitialized, which tracks
// process-level JS-bridge registration state.
fun invalidate() {
instance = null
}

@TestOnly
fun resetIsRnSdkInitialized() {
isRnSdkInitialized = false
}

@TestOnly
fun isRnSdkInitializedForTests(): Boolean = isRnSdkInitialized
}

private var reactContext: ReactContext? = null
// Cached so it can be delivered once the bridge becomes available.
private var lastSessionId: String? = null

// Set from onHostResume; null until the React activity is first resumed.
private var reactContext: ReactContext? = null
// Overridable in tests — NativeArray cannot be instantiated without the native SO.
private var convertToNativeArray: ((array: Array<String>) -> NativeArray?)? = null
private var exceptionHandler: ((error:Exception)->Unit)? = null
// Overridable in tests to assert on bridge exceptions without crashing.
private var exceptionHandler: ((error: Exception) -> Unit)? = null
// Lazily resolved from BuildConfig; overridable in tests.
private var isNewArchitecture: Boolean? = null

// Stores the session ID and attempts immediate delivery.
override fun onSessionStarted(sessionId: String, isDiscarded: Boolean) {
sendSessionStartedToJS(sessionId)
this.lastSessionId = sessionId
trySendSessionStartedToJS(sessionId)
}

// Stores the React context and schedules a catch-up delivery for any cached session
// ID on the UI thread. Called from:
// - DdSdk#onHostResume (both architectures) via the lifecycle listener
// - DdSdkImplementation#initialize (new-arch race fix — the TurboModule is lazy-
// instantiated, so the first onHostResume may have fired before its lifecycle
// listener was registered; setting the context here guarantees a non-null
// target for onRnSdkInitialized's replay)
//
Comment thread
marco-saia-datadog marked this conversation as resolved.
// Delivery is dispatched to the UI thread because sendSessionIdWithBridge and
// sendSessionIdWithEventEmitter are @MainThread. The field assignment stays
// synchronous so callers that rely on reactContext being set immediately after
// this call (e.g. a same-stack onRnSdkInitialized) see the new value.
fun setReactContext(reactContext: ReactContext) {
this.reactContext = reactContext
if (hasValidBridge()) {
this.lastSessionId?.let { sendSessionStartedToJS(it) }
}
val cached = this.lastSessionId ?: return
uiThreadExecutor.runOnUiThread { trySendSessionStartedToJS(cached) }
}

// Called when the RN SDK is initialized from JS for the first time while the native
// SDK was already running. At this point DatadogInternalReactBridge is guaranteed to
// be registered, so it is safe to deliver any session ID that was stored before the
// React bridge was available. Delivery is posted to the UI thread for the same
// reason as setReactContext.
fun onRnSdkInitialized() {
isRnSdkInitialized = true
val cached = this.lastSessionId ?: return
uiThreadExecutor.runOnUiThread { trySendSessionStartedToJS(cached) }
}

@TestOnly
Expand All @@ -65,14 +120,28 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList
this.isNewArchitecture = isNewArch
}

@TestOnly
fun setIsRnSdkInitialized(value: Boolean) {
isRnSdkInitialized = value
}

@TestOnly
fun setUiThreadExecutor(executor: UiThreadExecutor) {
uiThreadExecutor = executor
}

// Returns true only when it is safe to call callFunction on the JS thread.
private fun hasValidBridge(): Boolean {
val context = reactContext ?: return false
val instance = context.catalystInstance ?: return false
return !isNewArchitecture() &&
!instance.isDestroyed &&
context.hasActiveReactInstance()
context.hasActiveReactInstance() &&
isRnSdkInitialized
}

// Lazily cached — BuildConfig is a compile-time constant but reading it through the
// nullable override lets tests inject the value without reflection.
private fun isNewArchitecture(): Boolean {
isNewArchitecture?.let { return it }
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED.let {
Expand All @@ -81,39 +150,41 @@ internal class DdSdkSessionStartedListener private constructor(): RumSessionList
}
}

private fun sendSessionStartedToJS(sessionId: String) {
this.lastSessionId = sessionId
// Routes delivery to the appropriate path based on arch and bridge readiness.
private fun trySendSessionStartedToJS(sessionId: String) {
if (hasValidBridge()) {
sendSessionIdWithBridge(sessionId)
} else {
sendSessionIdWithEventEmitter(sessionId)
}
}

// Old-arch delivery path via BatchedBridge.
@MainThread
private fun sendSessionIdWithBridge(sessionId: String) {
@Suppress("TooGenericExceptionCaught")
try {
val args = arrayOf(sessionId)
val nativeArray = if (convertToNativeArray != null) {
convertToNativeArray?.invoke(args)
} else {
WritableNativeArray().apply {
pushString("RUMSessionStarted")
pushString(sessionId)
}
}

val args = arrayOf("RUMSessionStarted", sessionId)
reactContext?.catalystInstance?.callFunction(
BRIDGE_MODULE_NAME,
BRIDGE_MODULE_METHOD,
nativeArray
buildBridgeArgs(args)
)
} catch(err: Exception) {
} catch (err: Exception) {
exceptionHandler?.invoke(err)
}
}

private fun buildBridgeArgs(args: Array<String>): NativeArray? {
if (convertToNativeArray != null) {
return convertToNativeArray?.invoke(args)
}
return WritableNativeArray().apply {
args.forEach { pushString(it) }
}
}

// New-arch delivery path and fallback when the bridge is not active.
@MainThread
private fun sendSessionIdWithEventEmitter(sessionId: String) {
val context = reactContext ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package com.datadog.reactnative

import android.content.pm.PackageInfo
import com.datadog.android.rum.RumMonitor
import com.datadog.tools.unit.GenericAssert.Companion.assertThat
import com.datadog.tools.unit.forge.BaseConfigurator
import com.facebook.react.bridge.ReactApplicationContext
Expand All @@ -24,8 +25,12 @@ import org.mockito.Answers
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.mockito.quality.Strictness

Expand Down Expand Up @@ -66,6 +71,9 @@ internal class DdSdkNativeInitializationTest {
mockDatadog,
mockJSONFileReader
)

DdSdkSessionStartedListener.invalidate()
DdSdkSessionStartedListener.resetIsRnSdkInitialized()
}

// region getConfigurationFromJSONFile
Expand Down Expand Up @@ -180,4 +188,64 @@ internal class DdSdkNativeInitializationTest {
}

// endregion

// region initialize()

@Test
fun `𝕄 mark RN SDK initialized and catch up session 𝕎 initialize() { isCalledFromJs=true, datadog already initialized }`() { // ktlint-disable-line max-line-length
// Given
val mockRumMonitor: RumMonitor = mock()
whenever(mockDatadog.isInitialized()) doReturn true
whenever(mockDatadog.getRumMonitor()) doReturn mockRumMonitor

// When
testedNativeInitialization.initialize(
ddSdkConfiguration = minimalConfiguration(),
isCalledFromJs = true
)

// Then
assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isTrue()
verify(mockRumMonitor).getCurrentSessionId(any())
}

@Test
fun `𝕄 mark RN SDK initialized without catch up 𝕎 initialize() { isCalledFromJs=true, datadog not initialized }`() { // ktlint-disable-line max-line-length
// Given
whenever(mockDatadog.isInitialized()) doReturn false

// When
testedNativeInitialization.initialize(
ddSdkConfiguration = minimalConfiguration(),
isCalledFromJs = true
)

// Then
assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isTrue()
verify(mockDatadog, never()).getRumMonitor()
}

@Test
fun `𝕄 not mark RN SDK initialized or catch up 𝕎 initialize() { isCalledFromJs=false }`() {
// Given
whenever(mockDatadog.isInitialized()) doReturn true

// When
testedNativeInitialization.initialize(
ddSdkConfiguration = minimalConfiguration(),
isCalledFromJs = false
)

// Then
assertThat(DdSdkSessionStartedListener.isRnSdkInitializedForTests()).isFalse()
verify(mockDatadog, never()).getRumMonitor()
}

// endregion

private fun minimalConfiguration(): DdSdkConfiguration = DdSdkConfiguration(
clientToken = "fake-client-token",
env = "fake-env",
applicationId = "fake-app-id"
)
}
Loading