Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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
// 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, ddTelemetry)
nativeInitialization.initialize(ddSdkConfiguration)
Comment thread
marco-saia-datadog marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class DdSdkNativeInitialization internal constructor(
private val jsonFileReader: JSONFileReader = JSONFileReader()
) {
@Suppress("CyclomaticComplexMethod")
internal fun initialize(ddSdkConfiguration: DdSdkConfiguration) {
internal fun initialize(ddSdkConfiguration: DdSdkConfiguration, isCalledFromJs: Boolean = true) {
val sdkConfiguration = buildSdkConfiguration(ddSdkConfiguration)
val trackingConsent = buildTrackingConsent(ddSdkConfiguration.trackingConsent)
var rumConfiguration: RumConfiguration? = null
Expand All @@ -71,10 +71,14 @@ class DdSdkNativeInitialization internal constructor(

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)
}
}
}
}
Comment thread
marco-saia-datadog marked this conversation as resolved.
Expand Down Expand Up @@ -413,7 +417,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.
private var isRnSdkInitialized: Boolean = false

// Routes UI-thread dispatch through an injectable abstraction so JVM unit tests
// can use TestUiThreadExecutor (synchronous, no main looper required) instead of
// the real 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

Comment thread
marco-saia-datadog marked this conversation as resolved.
// 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)
//
// 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 @@ -70,6 +75,9 @@ internal class DdSdkNativeInitializationTest {
mockDdTelemetry,
mockJSONFileReader
)

DdSdkSessionStartedListener.invalidate()
DdSdkSessionStartedListener.resetIsRnSdkInitialized()
}

// region getConfigurationFromJSONFile
Expand Down Expand Up @@ -202,4 +210,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",
additionalConfiguration = emptyMap()
)
}
Loading
Loading