From 9579ed3c91553615562c7e2b8b1c62cfab0877b7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 4 Oct 2026 01:50:29 -0700 Subject: [PATCH 1/6] feat: pass sessionOnError and sessionReplayOnError to the native SDKs Adds two init options to the SDK configuration, both off by default: - sessionOnError keeps the sessions sessionSamplingRate leaves out, but only those that report an error. The native SDKs hold such a session in memory and upload its last minute when it reports one. - sessionReplayOnError does the same for the replays the Session Replay sample rate leaves out. Both sit on the SDK configuration rather than on SessionReplay.enable: on Android the replay switch belongs to the RUM configuration, built when the SDK starts, before Session Replay is enabled. On iOS it belongs to the Session Replay configuration, so the bridge keeps it and hands it over when Session Replay is enabled. Both are also read from datadog-configuration.json for native initialization. JS errors reach the native addError path, so they release a held session; an error dropped by errorEventMapper never reaches native code. On Android an error that error tracking also logs through DdLogs reaches RUM again from the native logger, which releases the session unless logEventMapper drops that log as well; the option's documentation says so. Needs native SDK releases that provide these options. --- docs/troubleshooting_no_data.md | 3 +- packages/core/README.md | 4 ++ .../datadog/reactnative/DdSdkConfiguration.kt | 10 +++- .../reactnative/DdSdkConfigurationExt.kt | 12 ++++- .../reactnative/DdSdkNativeInitialization.kt | 2 + .../DdSdkNativeInitializationTest.kt | 4 ++ .../com/datadog/reactnative/DdSdkTest.kt | 48 ++++++++++++++++++ .../tools/unit/DdSdkConfigurationExt.kt | 2 + .../forge/DdSdkConfigurationForgeryFactory.kt | 2 + .../input/complete-configuration.json | 2 + .../core/datadog-configuration.schema.json | 8 +++ .../core/ios/Sources/DatadogSDKWrapper.swift | 4 ++ .../core/ios/Sources/DdSdkConfiguration.swift | 10 +++- .../Sources/DdSdkNativeInitialization.swift | 5 +- .../ios/Sources/RNDdSdkConfiguration.swift | 14 +++++- .../DdSdkNativeInitializationTests.swift | 4 ++ packages/core/ios/Tests/DdSdkTests.swift | 50 +++++++++++++++++-- .../Fixtures/complete-configuration.json | 2 + packages/core/src/DdSdkReactNative.tsx | 4 +- .../src/DdSdkReactNativeConfiguration.tsx | 26 ++++++++++ .../src/__tests__/DdSdkReactNative.test.tsx | 44 ++++++++++++++++ .../DdSdkReactNativeConfiguration.test.ts | 10 +++- .../__tests__/initialization.test.tsx | 2 + .../__tests__/FileBasedConfiguration.test.ts | 4 ++ packages/core/src/types.tsx | 6 ++- .../react-native-session-replay/README.md | 6 +++ .../DdSessionReplayImplementation.swift | 2 + .../ios/Tests/DdSessionReplayTests.swift | 28 ++++++++++- 28 files changed, 301 insertions(+), 17 deletions(-) diff --git a/docs/troubleshooting_no_data.md b/docs/troubleshooting_no_data.md index 06c83ea70..3c47ae593 100644 --- a/docs/troubleshooting_no_data.md +++ b/docs/troubleshooting_no_data.md @@ -120,7 +120,8 @@ requests carry no tracing headers by design. in the console. A token that is valid but belongs to another application produces a silent no-op, not an error. - **Sampling.** `sessionSamplingRate` is a percentage of *sessions*. At a low value most test - runs legitimately report nothing; set it to `100` while integrating. + runs legitimately report nothing; set it to `100` while integrating. With `sessionOnError`, + a session the rate left out sends nothing at all until it reports an error. - **Consent.** Nothing is collected under `TrackingConsent.NOT_GRANTED`, and events collected under `PENDING` are discarded unless consent is later granted. diff --git a/packages/core/README.md b/packages/core/README.md index 12b14b8bb..1e8ea3320 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -53,6 +53,10 @@ datadogConfiguration.site = 'CN'; datadogConfiguration.nativeCrashReportEnabled = true; // Optional: sample RUM sessions (here, 80% of sessions are reported. Default = 100%) datadogConfiguration.sessionSamplingRate = 80; +// Optional: also keep the sessions left out by `sessionSamplingRate` that report an error. They are +// held in memory and send nothing until their first error, which uploads the minute before it. +// Default = false +datadogConfiguration.sessionOnError = true; // Optional: sample the tracing integration for calls between your app and your backend (here, 80% of // calls to your instrumented backend are linked from the RUM view to the trace. Default = 20%) // You need to specify the hosts of your backends to enable tracing with these backends diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfiguration.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfiguration.kt index bc598d005..2902726d4 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfiguration.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfiguration.kt @@ -43,6 +43,8 @@ import java.net.Proxy * @param trackNonFatalAnrs Enables tracking of non-fatal ANRs on Android. * @param batchProcessingLevel The preferred number of batches of data that will be sent in a single upload (can be 'LOW', 'MEDIUM' (default), 'HIGH') * @param initialResourceThreshold "The amount of time after a view starts where a Resource should be considered when calculating Time to Network-Settled (TNS)" + * @param sessionOnError Also keeps the sessions the sample rate leaves out, but only those that report an error. + * @param sessionReplayOnError Also keeps the replays the Session Replay sample rate leaves out, but only those of sessions that report an error. */ data class DdSdkConfiguration( val clientToken: String, @@ -73,7 +75,9 @@ data class DdSdkConfiguration( val bundleLogsWithTraces: Boolean? = null, val trackNonFatalAnrs: Boolean? = null, val batchProcessingLevel: String? = null, - val initialResourceThreshold: Double? = null + val initialResourceThreshold: Double? = null, + val sessionOnError: Boolean? = null, + val sessionReplayOnError: Boolean? = null ) internal data class JSONConfigurationFile( @@ -107,7 +111,9 @@ internal data class JSONDdSdkConfiguration( val bundleLogsWithTraces: Boolean? = null, val trackNonFatalAnrs: Boolean? = null, val batchProcessingLevel: String? = null, - val initialResourceThreshold: Double? = null + val initialResourceThreshold: Double? = null, + val sessionOnError: Boolean? = null, + val sessionReplayOnError: Boolean? = null ) internal data class JSONProxyConfiguration( diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfigurationExt.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfigurationExt.kt index 242ac1ac6..9c3bcb7d4 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfigurationExt.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkConfigurationExt.kt @@ -49,7 +49,9 @@ internal fun ReadableMap.asDdSdkConfiguration(): DdSdkConfiguration { bundleLogsWithTraces = getBoolean("bundleLogsWithTraces"), trackNonFatalAnrs = getBooleanOrNull("trackNonFatalAnrs"), batchProcessingLevel = getString("batchProcessingLevel"), - initialResourceThreshold = getDoubleOrNull("initialResourceThreshold") + initialResourceThreshold = getDoubleOrNull("initialResourceThreshold"), + sessionOnError = getBooleanOrNull("sessionOnError"), + sessionReplayOnError = getBooleanOrNull("sessionReplayOnError") ) } @@ -140,6 +142,8 @@ internal object DefaultConfiguration { const val bundleLogsWithRum = true const val bundleLogsWithTraces = true const val initialResourceThreshold = 0.1 + const val sessionOnError = false + const val sessionReplayOnError = false } @Suppress("CyclomaticComplexMethod") @@ -176,7 +180,9 @@ internal fun JSONDdSdkConfiguration.asDdSdkConfiguration(): DdSdkConfiguration { this.bundleLogsWithTraces ?: DefaultConfiguration.bundleLogsWithTraces, this.trackNonFatalAnrs, this.batchProcessingLevel, - this.initialResourceThreshold ?: DefaultConfiguration.initialResourceThreshold + this.initialResourceThreshold ?: DefaultConfiguration.initialResourceThreshold, + this.sessionOnError ?: DefaultConfiguration.sessionOnError, + this.sessionReplayOnError ?: DefaultConfiguration.sessionReplayOnError ) } @@ -241,6 +247,8 @@ internal fun DdSdkConfiguration.toReadableMap(): ReadableMap { trackNonFatalAnrs?.let { map.putBoolean("trackNonFatalAnrs", it) } additionalConfig?.let { map.putMap("additionalConfig", it.toWritableMap()) } initialResourceThreshold?.let { map.putDouble("initialResourceThreshold", it)} + sessionOnError?.let { map.putBoolean("sessionOnError", it) } + sessionReplayOnError?.let { map.putBoolean("sessionReplayOnError", it) } return map } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt index c7ebaaf1a..1dfd40388 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt @@ -117,6 +117,8 @@ class DdSdkNativeInitialization internal constructor( if (configuration.sampleRate != null) { configBuilder.setSessionSampleRate(configuration.sampleRate.toFloat()) } + configBuilder.setSessionOnError(configuration.sessionOnError ?: false) + configBuilder.setSessionReplayOnError(configuration.sessionReplayOnError ?: false) configBuilder.trackFrustrations(configuration.trackFrustrations ?: true) configBuilder.trackBackgroundEvents(configuration.trackBackgroundEvents ?: false) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt index d05d43c57..d3a4d449f 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkNativeInitializationTest.kt @@ -121,6 +121,8 @@ internal class DdSdkNativeInitializationTest { "[B3MULTI, TRACECONTEXT]" ) assertThat(configuration.initialResourceThreshold).isEqualTo(0.5) + assertThat(configuration.sessionOnError).isEqualTo(true) + assertThat(configuration.sessionReplayOnError).isEqualTo(true) } @Test @@ -162,6 +164,8 @@ internal class DdSdkNativeInitializationTest { assertThat(configuration.proxyConfig).isNull() assertThat(configuration.firstPartyHosts).isNull() assertThat(configuration.initialResourceThreshold).isEqualTo(0.1) + assertThat(configuration.sessionOnError).isEqualTo(false) + assertThat(configuration.sessionReplayOnError).isEqualTo(false) } @Test diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index f2fa57e88..3588b57eb 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -1366,6 +1366,54 @@ internal class DdSdkTest { } } + @Test + fun `𝕄 initialize native SDK 𝕎 initialize() {sessionOnError, sessionReplayOnError}`( + @Forgery configuration: DdSdkConfiguration + ) { + // Given + val bridgeConfiguration = configuration.copy( + sampleRate = 0.0, + sessionOnError = true, + sessionReplayOnError = true + ) + val rumConfigCaptor = argumentCaptor() + + // When + testedBridgeSdk.initialize(bridgeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + verify(mockDatadog).enableRum(rumConfigCaptor.capture()) + assertThat(rumConfigCaptor.firstValue) + .hasField("featureConfiguration") { + it.hasFieldEqualTo("sampleRate", 0f) + it.hasFieldEqualTo("sessionOnError", true) + it.hasFieldEqualTo("sessionReplayOnError", true) + } + } + + @Test + fun `𝕄 initialize native SDK with the on-error switches off 𝕎 initialize() {switches not set}`( + @Forgery configuration: DdSdkConfiguration + ) { + // Given + val bridgeConfiguration = configuration.copy( + sessionOnError = null, + sessionReplayOnError = null + ) + val rumConfigCaptor = argumentCaptor() + + // When + testedBridgeSdk.initialize(bridgeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + verify(mockDatadog).enableRum(rumConfigCaptor.capture()) + assertThat(rumConfigCaptor.firstValue) + .hasField("featureConfiguration") { + it.hasFieldEqualTo("sessionOnError", false) + it.hasFieldEqualTo("sessionReplayOnError", false) + } + } + @Test fun `𝕄 initialize native SDK 𝕎 initialize() {rare vitals frequency update}`( @Forgery configuration: DdSdkConfiguration diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/DdSdkConfigurationExt.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/DdSdkConfigurationExt.kt index e1208bbc5..cddca8dfe 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/DdSdkConfigurationExt.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/DdSdkConfigurationExt.kt @@ -101,6 +101,8 @@ fun DdSdkConfiguration.toReadableJavaOnlyMap(): ReadableMap { map.put("bundleLogsWithTraces", bundleLogsWithTraces) trackNonFatalAnrs?.let { map.put("trackNonFatalAnrs", it) } + sessionOnError?.let { map.put("sessionOnError", it) } + sessionReplayOnError?.let { map.put("sessionReplayOnError", it) } if (batchProcessingLevel != null) { map["batchProcessingLevel"] = batchProcessingLevel diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/DdSdkConfigurationForgeryFactory.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/DdSdkConfigurationForgeryFactory.kt index baeb18058..1b5a03dd1 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/DdSdkConfigurationForgeryFactory.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/DdSdkConfigurationForgeryFactory.kt @@ -81,6 +81,8 @@ class DdSdkConfigurationForgeryFactory : ForgeryFactory { bundleLogsWithRum = forge.aBool(), bundleLogsWithTraces = forge.aBool(), trackNonFatalAnrs = forge.aBool(), + sessionOnError = forge.aNullable { aBool() }, + sessionReplayOnError = forge.aNullable { aBool() }, batchProcessingLevel = forge.aNullable { anElementFrom( "LOW", diff --git a/packages/core/android/src/test/resources/input/complete-configuration.json b/packages/core/android/src/test/resources/input/complete-configuration.json index 9c9fcaa7a..7c04ecbeb 100644 --- a/packages/core/android/src/test/resources/input/complete-configuration.json +++ b/packages/core/android/src/test/resources/input/complete-configuration.json @@ -41,6 +41,8 @@ }, "serviceName": "my.app", "sessionSamplingRate": 80, + "sessionOnError": true, + "sessionReplayOnError": true, "site": "US3", "telemetrySampleRate": 60, "trackBackgroundEvents": true, diff --git a/packages/core/datadog-configuration.schema.json b/packages/core/datadog-configuration.schema.json index 02ab83927..e6c26e9ad 100644 --- a/packages/core/datadog-configuration.schema.json +++ b/packages/core/datadog-configuration.schema.json @@ -37,6 +37,14 @@ "description": "Percentage of sampled RUM sessions. Range `0`-`100`.", "type": "integer" }, + "sessionOnError": { + "description": "Also keeps the sessions `sessionSamplingRate` leaves out, but only those that report an error. Defaults to `false`.", + "type": "boolean" + }, + "sessionReplayOnError": { + "description": "Also keeps the replays the Session Replay sample rate leaves out, but only those of sessions that report an error. Needs Session Replay. Defaults to `false`.", + "type": "boolean" + }, "site": { "description": "The Flashcat site to send data to (can be 'CN' or 'STAGING', default is 'CN').", "type": "string", diff --git a/packages/core/ios/Sources/DatadogSDKWrapper.swift b/packages/core/ios/Sources/DatadogSDKWrapper.swift index 9cbac3bd8..0a4d3d4f6 100644 --- a/packages/core/ios/Sources/DatadogSDKWrapper.swift +++ b/packages/core/ios/Sources/DatadogSDKWrapper.swift @@ -29,6 +29,10 @@ public class DatadogSDKWrapper { internal var loggerConfiguration = DatadogLogs.Logger.Configuration() // Core instance private var coreInstance: DatadogCoreProtocol? = nil + /// Whether to keep the replays the replay sample rate leaves out in case their session reports + /// an error. Set with the rest of the SDK configuration, and read by Session Replay, which on + /// iOS carries this switch in its own configuration. + public internal(set) var sessionReplayOnError = false private init() { } diff --git a/packages/core/ios/Sources/DdSdkConfiguration.swift b/packages/core/ios/Sources/DdSdkConfiguration.swift index 1c8b33d6c..613b90e9e 100644 --- a/packages/core/ios/Sources/DdSdkConfiguration.swift +++ b/packages/core/ios/Sources/DdSdkConfiguration.swift @@ -42,6 +42,8 @@ import DatadogRUM - trackWatchdogTerminations: Whether the SDK should track application termination by the watchdog - batchProcessingLevel: Maximum number of batches processed sequentially without a delay - initialResourceThreshold: The amount of time after a view starts where a Resource should be considered when calculating Time to Network-Settled (TNS) + - sessionOnError: Also keeps the sessions the sample rate leaves out, but only those that report an error. + - sessionReplayOnError: Also keeps the replays the Session Replay sample rate leaves out, but only those of sessions that report an error. */ @objc(DdSdkConfiguration) public class DdSdkConfiguration: NSObject { @@ -76,6 +78,8 @@ public class DdSdkConfiguration: NSObject { public var trackWatchdogTerminations: Bool public var batchProcessingLevel: Datadog.Configuration.BatchProcessingLevel public var initialResourceThreshold: Double? = nil + public var sessionOnError: Bool + public var sessionReplayOnError: Bool public init( clientToken: String, @@ -108,7 +112,9 @@ public class DdSdkConfiguration: NSObject { appHangThreshold: Double?, trackWatchdogTerminations: Bool, batchProcessingLevel: Datadog.Configuration.BatchProcessingLevel, - initialResourceThreshold: Double? + initialResourceThreshold: Double?, + sessionOnError: Bool, + sessionReplayOnError: Bool ) { self.clientToken = clientToken self.env = env @@ -141,6 +147,8 @@ public class DdSdkConfiguration: NSObject { self.trackWatchdogTerminations = trackWatchdogTerminations self.batchProcessingLevel = batchProcessingLevel self.initialResourceThreshold = initialResourceThreshold + self.sessionOnError = sessionOnError + self.sessionReplayOnError = sessionReplayOnError } } diff --git a/packages/core/ios/Sources/DdSdkNativeInitialization.swift b/packages/core/ios/Sources/DdSdkNativeInitialization.swift index 0ddad2f43..3b5686e14 100644 --- a/packages/core/ios/Sources/DdSdkNativeInitialization.swift +++ b/packages/core/ios/Sources/DdSdkNativeInitialization.swift @@ -80,6 +80,7 @@ public class DdSdkNativeInitialization: NSObject { } func enableFeatures(sdkConfiguration: DdSdkConfiguration) { + DatadogSDKWrapper.shared.sessionReplayOnError = sdkConfiguration.sessionReplayOnError let rumConfig = buildRUMConfiguration(configuration: sdkConfiguration) DatadogSDKWrapper.shared.enableRUM(with: rumConfig) @@ -169,7 +170,7 @@ public class DdSdkNativeInitialization: NSObject { networkSettledResourcePredicate = TimeBasedTNSResourcePredicate(threshold: initialResourceThreshold) } - return RUM.Configuration( + var rumConfiguration = RUM.Configuration( applicationID: configuration.applicationId, sessionSampleRate: (configuration.sampleRate as? NSNumber)?.floatValue ?? Float(DefaultConfiguration.sessionSamplingRate), uiKitViewsPredicate: uiKitViewsPredicate, @@ -198,6 +199,8 @@ public class DdSdkNativeInitialization: NSObject { customEndpoint: customRUMEndpointURL, telemetrySampleRate: (configuration.telemetrySampleRate as? NSNumber)?.floatValue ?? Float(DefaultConfiguration.telemetrySampleRate) ) + rumConfiguration.sessionOnError = configuration.sessionOnError + return rumConfiguration } func buildLogsConfiguration(configuration: DdSdkConfiguration) -> Logs.Configuration { diff --git a/packages/core/ios/Sources/RNDdSdkConfiguration.swift b/packages/core/ios/Sources/RNDdSdkConfiguration.swift index 2d486672b..7b55764c1 100644 --- a/packages/core/ios/Sources/RNDdSdkConfiguration.swift +++ b/packages/core/ios/Sources/RNDdSdkConfiguration.swift @@ -43,6 +43,8 @@ extension NSDictionary { let trackWatchdogTerminations = object(forKey: "trackWatchdogTerminations") as? Bool let batchProcessingLevel = object(forKey: "batchProcessingLevel") as? NSString let initialResourceThreshold = object(forKey: "initialResourceThreshold") as? Double + let sessionOnError = object(forKey: "sessionOnError") as? Bool + let sessionReplayOnError = object(forKey: "sessionReplayOnError") as? Bool return DdSdkConfiguration( clientToken: (clientToken != nil) ? clientToken! : String(), @@ -75,7 +77,9 @@ extension NSDictionary { appHangThreshold: appHangThreshold, trackWatchdogTerminations: trackWatchdogTerminations ?? DefaultConfiguration.trackWatchdogTerminations, batchProcessingLevel: batchProcessingLevel.asBatchProcessingLevel(), - initialResourceThreshold: initialResourceThreshold + initialResourceThreshold: initialResourceThreshold, + sessionOnError: sessionOnError ?? DefaultConfiguration.sessionOnError, + sessionReplayOnError: sessionReplayOnError ?? DefaultConfiguration.sessionReplayOnError ) } @@ -206,6 +210,8 @@ internal struct DefaultConfiguration { static let bundleLogsWithRum = true static let bundleLogsWithTraces = true static let trackWatchdogTerminations = false + static let sessionOnError = false + static let sessionReplayOnError = false } extension Dictionary where Key == String, Value == AnyObject { @@ -244,6 +250,8 @@ extension Dictionary where Key == String, Value == AnyObject { let trackWatchdogTerminations = configuration["trackWatchdogTerminations"] as? Bool let batchProcessingLevel = configuration["batchProcessingLevel"] as? NSString let initialResourceThreshold = configuration["initialResourceThreshold"] as? Double + let sessionOnError = configuration["sessionOnError"] as? Bool + let sessionReplayOnError = configuration["sessionReplayOnError"] as? Bool return DdSdkConfiguration( clientToken: clientToken ?? String(), @@ -279,7 +287,9 @@ extension Dictionary where Key == String, Value == AnyObject { appHangThreshold: appHangThreshold, trackWatchdogTerminations: trackWatchdogTerminations ?? DefaultConfiguration.trackWatchdogTerminations, batchProcessingLevel: batchProcessingLevel.asBatchProcessingLevel(), - initialResourceThreshold: initialResourceThreshold + initialResourceThreshold: initialResourceThreshold, + sessionOnError: sessionOnError ?? DefaultConfiguration.sessionOnError, + sessionReplayOnError: sessionReplayOnError ?? DefaultConfiguration.sessionReplayOnError ) } } diff --git a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift index 563e22ced..84140be4b 100644 --- a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift +++ b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift @@ -59,6 +59,8 @@ class DdSdkNativeInitializationTests: XCTestCase { let expectedFirstPartyHosts: [String: Set]? = ["example.com": [.b3multi, .tracecontext]] XCTAssertEqual(configuration?.firstPartyHosts, expectedFirstPartyHosts) XCTAssertEqual(configuration?.initialResourceThreshold, 0.5) + XCTAssertEqual(configuration?.sessionOnError, true) + XCTAssertEqual(configuration?.sessionReplayOnError, true) } func testReturnsConfigurationWithMinimalData() { @@ -95,6 +97,8 @@ class DdSdkNativeInitializationTests: XCTestCase { let expectedFirstPartyHosts: [String: Set]? = [:] XCTAssertEqual(configuration?.firstPartyHosts, expectedFirstPartyHosts) XCTAssertEqual(configuration?.initialResourceThreshold, nil) + XCTAssertEqual(configuration?.sessionOnError, false) + XCTAssertEqual(configuration?.sessionReplayOnError, false) } func testPrintsMessageWithIncorrectFile() { diff --git a/packages/core/ios/Tests/DdSdkTests.swift b/packages/core/ios/Tests/DdSdkTests.swift index 24f91087d..cb0e15929 100644 --- a/packages/core/ios/Tests/DdSdkTests.swift +++ b/packages/core/ios/Tests/DdSdkTests.swift @@ -1119,6 +1119,42 @@ class DdSdkTests: XCTestCase { XCTAssertEqual(ddConfig.trackBackgroundEvents, false) } + func testSessionOnErrorIsOffByDefault() { + let configuration = NSDictionary.mockAny().asDdSdkConfiguration() + + let ddConfig = DdSdkNativeInitialization().buildRUMConfiguration( + configuration: configuration + ) + + XCTAssertFalse(configuration.sessionOnError) + XCTAssertFalse(configuration.sessionReplayOnError) + XCTAssertFalse(ddConfig.sessionOnError) + } + + func testSessionOnErrorEnabled() { + let configuration = NSDictionary.mockAny(sampleRate: 0, sessionOnError: true).asDdSdkConfiguration() + + let ddConfig = DdSdkNativeInitialization().buildRUMConfiguration( + configuration: configuration + ) + + XCTAssertEqual(ddConfig.sessionSampleRate, 0) + XCTAssertTrue(ddConfig.sessionOnError) + } + + func testEnableFeaturesHandsSessionReplayOnErrorToSessionReplay() { + defer { DatadogSDKWrapper.shared.sessionReplayOnError = false } + DatadogSDKWrapper.shared.setCoreInstance(core: MockDatadogCore()) + + for sessionReplayOnError in [true, false] { + let configuration = NSDictionary.mockAny(sessionReplayOnError: sessionReplayOnError).asDdSdkConfiguration() + + DdSdkNativeInitialization().enableFeatures(sdkConfiguration: configuration) + + XCTAssertEqual(DatadogSDKWrapper.shared.sessionReplayOnError, sessionReplayOnError) + } + } + func testConfigurationTelemetryOverride() throws { let core = MockDatadogCore() let configuration: DdSdkConfiguration = .mockAny( @@ -1379,7 +1415,9 @@ extension DdSdkConfiguration { appHangThreshold: Double? = nil, trackWatchdogTerminations: Bool = false, batchProcessingLevel: NSString? = "MEDIUM", - initialResourceThreshold: Double? = nil + initialResourceThreshold: Double? = nil, + sessionOnError: Bool = false, + sessionReplayOnError: Bool = false ) -> DdSdkConfiguration { DdSdkConfiguration( clientToken: clientToken as String, @@ -1412,7 +1450,9 @@ extension DdSdkConfiguration { appHangThreshold: appHangThreshold, trackWatchdogTerminations: trackWatchdogTerminations, batchProcessingLevel: batchProcessingLevel.asBatchProcessingLevel(), - initialResourceThreshold: initialResourceThreshold + initialResourceThreshold: initialResourceThreshold, + sessionOnError: sessionOnError, + sessionReplayOnError: sessionReplayOnError ) } } @@ -1444,7 +1484,9 @@ extension NSDictionary { firstPartyHosts: NSArray? = nil, bundleLogsWithRum: Bool? = nil, bundleLogsWithTraces: Bool? = nil, - batchProcessingLevel: NSString = "MEDIUM" + batchProcessingLevel: NSString = "MEDIUM", + sessionOnError: Bool? = nil, + sessionReplayOnError: Bool? = nil ) -> NSDictionary { var config = NSMutableDictionary() config["clientToken"] = clientToken @@ -1473,6 +1515,8 @@ extension NSDictionary { config["bundleLogsWithRum"] = bundleLogsWithRum config["bundleLogsWithTraces"] = bundleLogsWithTraces config["batchProcessingLevel"] = batchProcessingLevel + config["sessionOnError"] = sessionOnError + config["sessionReplayOnError"] = sessionReplayOnError return config } } diff --git a/packages/core/ios/Tests/Fixtures/complete-configuration.json b/packages/core/ios/Tests/Fixtures/complete-configuration.json index a68d7eee4..824b6b4a9 100644 --- a/packages/core/ios/Tests/Fixtures/complete-configuration.json +++ b/packages/core/ios/Tests/Fixtures/complete-configuration.json @@ -41,6 +41,8 @@ }, "serviceName": "my.app", "sessionSamplingRate": 80, + "sessionOnError": true, + "sessionReplayOnError": true, "site": "STAGING", "telemetrySampleRate": 60, "trackBackgroundEvents": true, diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 44ac83ee4..046c41b11 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -360,7 +360,9 @@ export class DdSdkReactNative { configuration.resourceTracingSamplingRate, configuration.trackWatchdogTerminations, configuration.batchProcessingLevel, - configuration.initialResourceThreshold + configuration.initialResourceThreshold, + configuration.sessionOnError, + configuration.sessionReplayOnError ); }; diff --git a/packages/core/src/DdSdkReactNativeConfiguration.tsx b/packages/core/src/DdSdkReactNativeConfiguration.tsx index 6eee5ba79..c6d38e784 100644 --- a/packages/core/src/DdSdkReactNativeConfiguration.tsx +++ b/packages/core/src/DdSdkReactNativeConfiguration.tsx @@ -120,6 +120,8 @@ export const formatFirstPartyHosts = ( export const DEFAULTS = { nativeCrashReportEnabled: false, sessionSamplingRate: 100.0, + sessionOnError: false, + sessionReplayOnError: false, resourceTracingSamplingRate: 20.0, site: 'CN', longTaskThresholdMs: 0, @@ -166,6 +168,28 @@ export class DdSdkReactNativeConfiguration { * Percentage of sampled RUM sessions. Range `0`-`100`. */ public sessionSamplingRate: number = DEFAULTS.sessionSamplingRate; + /** + * Also keeps the sessions `sessionSamplingRate` leaves out, but only those that report an error. + * + * Such a session is collected in memory without uploading anything, keeping only its last + * minute. If it reports an error, that minute is uploaded and the session carries on like any + * collected one; if it ends without one, nothing of it is ever sent. An error dropped by + * `errorEventMapper` does not count - but on Android, an error that error tracking also logs + * through `DdLogs` reaches RUM a second time from the native logger, which counts unless + * `logEventMapper` drops that log too. Its events report a session sample rate of 0, as it + * stands for itself rather than for the population a rate would imply. + * + * Default `false`. + */ + public sessionOnError: boolean = DEFAULTS.sessionOnError; + /** + * Also keeps the replays the Session Replay sample rate leaves out, but only those of sessions + * that report an error. A session kept only through `sessionOnError` holds its replay until its + * events are released. + * + * Needs `@flashcatcloud/mobile-react-native-session-replay`. Default `false`. + */ + public sessionReplayOnError: boolean = DEFAULTS.sessionReplayOnError; /** * Percentage of tracing integrations for network calls between your app and your backend. Range `0`-`100`. */ @@ -480,6 +504,8 @@ export type PartialInitializationConfiguration = { readonly env: string; readonly applicationId: string; readonly sessionSamplingRate?: number; + readonly sessionOnError?: boolean; + readonly sessionReplayOnError?: boolean; readonly site?: string; readonly verbosity?: SdkVerbosity | undefined; readonly nativeViewTracking?: boolean; diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 49c0bd1f2..0f76ae6d1 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -507,6 +507,50 @@ describe('DdSdkReactNative', () => { }) ); }); + + it('initializes with the on-error switches off by default', async () => { + // GIVEN + const configuration = new DdSdkReactNativeConfiguration( + '2', + 'env', + '1' + ); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + sessionOnError: false, + sessionReplayOnError: false + }) + ); + }); + + it('initializes with sessionOnError and sessionReplayOnError when they are specified', async () => { + // GIVEN + const configuration = new DdSdkReactNativeConfiguration( + '2', + 'env', + '1' + ); + configuration.sessionSamplingRate = 0; + configuration.sessionOnError = true; + configuration.sessionReplayOnError = true; + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + sampleRate: 0, + sessionOnError: true, + sessionReplayOnError: true + }) + ); + }); }); describe('feature enablement', () => { diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 4d5a8bcad..2b4320290 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -54,6 +54,8 @@ describe('DdSdkReactNativeConfiguration', () => { "resourceEventMapper": null, "resourceTracingSamplingRate": 20, "serviceName": undefined, + "sessionOnError": false, + "sessionReplayOnError": false, "sessionSamplingRate": 100, "site": "CN", "telemetrySampleRate": 20, @@ -123,7 +125,9 @@ describe('DdSdkReactNativeConfiguration', () => { }, bundleLogsWithRum: true, bundleLogsWithTraces: true, - initialResourceThreshold: 0.123 + initialResourceThreshold: 0.123, + sessionOnError: true, + sessionReplayOnError: true } ) ).toMatchInlineSnapshot(` @@ -165,6 +169,8 @@ describe('DdSdkReactNativeConfiguration', () => { "resourceEventMapper": [Function], "resourceTracingSamplingRate": 100, "serviceName": "com.test.app", + "sessionOnError": true, + "sessionReplayOnError": true, "sessionSamplingRate": 80, "site": "STAGING", "telemetrySampleRate": 20, @@ -242,6 +248,8 @@ describe('DdSdkReactNativeConfiguration', () => { "resourceEventMapper": null, "resourceTracingSamplingRate": 0, "serviceName": "", + "sessionOnError": false, + "sessionReplayOnError": false, "sessionSamplingRate": 0, "site": "", "telemetrySampleRate": 20, diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index e26a81a6e..ab2d42523 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -128,6 +128,8 @@ describe('DatadogProvider', () => { "resourceTracingSamplingRate": 20, "sampleRate": 100, "serviceName": undefined, + "sessionOnError": false, + "sessionReplayOnError": false, "site": "CN", "telemetrySampleRate": 20, "trackBackgroundEvents": false, diff --git a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts index be5b870be..724c53ad6 100644 --- a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts @@ -84,6 +84,8 @@ describe('FileBasedConfiguration', () => { "resourceEventMapper": null, "resourceTracingSamplingRate": 33, "serviceName": undefined, + "sessionOnError": false, + "sessionReplayOnError": false, "sessionSamplingRate": 100, "site": "STAGING", "telemetrySampleRate": 20, @@ -138,6 +140,8 @@ describe('FileBasedConfiguration', () => { "resourceEventMapper": null, "resourceTracingSamplingRate": 20, "serviceName": undefined, + "sessionOnError": false, + "sessionReplayOnError": false, "sessionSamplingRate": 100, "site": "CN", "telemetrySampleRate": 20, diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index 4db877469..e68d1ca9e 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -68,8 +68,10 @@ export class DdSdkConfiguration { readonly appHangThreshold: number | undefined, readonly resourceTracingSamplingRate: number, readonly trackWatchdogTerminations: boolean | undefined, - readonly batchProcessingLevel: BatchProcessingLevel, // eslint-disable-next-line no-empty-function - readonly initialResourceThreshold: number | undefined + readonly batchProcessingLevel: BatchProcessingLevel, + readonly initialResourceThreshold: number | undefined, + readonly sessionOnError: boolean, + readonly sessionReplayOnError: boolean // eslint-disable-next-line no-empty-function ) {} } diff --git a/packages/react-native-session-replay/README.md b/packages/react-native-session-replay/README.md index 3168b7e84..417a3d112 100644 --- a/packages/react-native-session-replay/README.md +++ b/packages/react-native-session-replay/README.md @@ -35,6 +35,12 @@ SessionReplay.enable({ **Note**: All configuration properties are optional and should be adjusted based on your application's needs. +To also keep the replays `replaySampleRate` leaves out, but only for sessions that report an error, set `sessionReplayOnError` on the SDK configuration (not on `SessionReplay.enable`), next to `sessionOnError`: + +```js +datadogConfiguration.sessionReplayOnError = true; +``` + ## Start or stop the recording manually By default, Session Replay starts recording automatically. However, if you prefer to manually start recording at a specific point in your application, you can use the optional `startRecordingImmediately` parameter as shown below, and later call `SessionReplay.startRecording()`. You can also use `SessionReplay.stopRecording()` to stop the recording anytime. diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index 4c84c61c9..d201eed02 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -81,6 +81,8 @@ public class DdSessionReplayImplementation: NSObject { } } + sessionReplayConfiguration.sessionReplayOnError = DatadogSDKWrapper.shared.sessionReplayOnError + sessionReplayConfiguration.setAdditionalNodeRecorders([ SvgViewRecorder( uiManager: uiManager, diff --git a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift index db410be74..1945f0646 100644 --- a/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift +++ b/packages/react-native-session-replay/ios/Tests/DdSessionReplayTests.swift @@ -7,7 +7,7 @@ import XCTest @testable import DatadogSDKReactNativeSessionReplay import DatadogSessionReplay -import DatadogSDKReactNative +@testable import DatadogSDKReactNative import DatadogInternal import React @@ -79,6 +79,30 @@ internal class DdSessionReplayTests: XCTestCase { )) } + func testEnablesSessionReplayWithTheSessionReplayOnErrorOfTheSDKConfiguration() { + for sessionReplayOnError in [true, false] { + let sessionReplayMock = MockSessionReplay() + DatadogSDKWrapper.shared.sessionReplayOnError = sessionReplayOnError + defer { DatadogSDKWrapper.shared.sessionReplayOnError = false } + + DdSessionReplayImplementation( + sessionReplayProvider: { sessionReplayMock }, + uiManager: MockUIManager(), + fabricWrapper: MockFabricWrapper() + ).enable( + replaySampleRate: 0, + customEndpoint: "", + imagePrivacyLevel: "MASK_ALL", + touchPrivacyLevel: "HIDE", + textAndInputPrivacyLevel: "MASK_ALL", + startRecordingImmediately: true, + resolve: mockResolve, + reject: mockReject) + + XCTAssertEqual(sessionReplayMock.enabledConfiguration?.sessionReplayOnError, sessionReplayOnError) + } + } + func testEnablesSessionReplayWithBadPrivacyLevels() { let sessionReplayMock = MockSessionReplay() let uiManagerMock = MockUIManager() @@ -165,8 +189,10 @@ private class MockSessionReplay: SessionReplayProtocol { } public var calledMethods = [CalledMethod]() + public var enabledConfiguration: SessionReplay.Configuration? func enable(with configuration: SessionReplay.Configuration, in core: DatadogCoreProtocol) { + enabledConfiguration = configuration calledMethods.append( .enable( replaySampleRate: configuration.replaySampleRate, From ba067af394240d4783273c71b9dce538b35e6bac Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 02:50:00 -0700 Subject: [PATCH 2/6] fix: report each tracked JS error to RUM once JS error tracking reports an error to RUM and, flagged with `_dd.error_log.is_crash`, to Logs. The native logs feature forwards every error-level log back to RUM as a `logger` error, so each tracked JS error produced two RUM errors, and the second one bypassed the JS `errorEventMapper`: an error the mapper dropped still counted - and, with `sessionOnError`, still released a withheld session. Both bridges now set a native RUM error mapper that drops the logger copy of a tracked error; logs the application sends itself are untouched. On iOS, `sessionReplayOnError` is now published before the core is handed to on-core-initialized listeners, so Session Replay enabled from such a listener reads the configured value. --- .../reactnative/DdSdkImplementation.kt | 1 + .../reactnative/DdSdkNativeInitialization.kt | 19 ++++++ .../com/datadog/reactnative/DdSdkTest.kt | 68 ++++++++++++++++++- .../tools/unit/forge/BaseConfigurator.kt | 1 + .../unit/forge/ErrorEventForgeryFactory.kt | 50 ++++++++++++++ packages/core/ios/Sources/Attributes.swift | 5 ++ .../Sources/DdSdkNativeInitialization.swift | 15 +++- packages/core/ios/Tests/DdSdkTests.swift | 34 ++++++++-- packages/core/ios/Tests/RUMMocks.swift | 17 +++++ .../src/DdSdkReactNativeConfiguration.tsx | 4 +- .../react-native-session-replay/README.md | 2 +- 11 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/ErrorEventForgeryFactory.kt diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index cdd6b0614..90d20fd41 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -276,6 +276,7 @@ class DdSdkImplementation( internal const val DD_VERSION_SUFFIX = "_dd.version_suffix" internal const val DD_DROP_RESOURCE = "_dd.resource.drop_resource" internal const val DD_DROP_ACTION = "_dd.action.drop_action" + internal const val DD_ERROR_LOG_IS_CRASH = "_dd.error_log.is_crash" internal const val MONITOR_JS_ERROR_MESSAGE = "Error monitoring JS refresh rate" internal const val PACKAGE_INFO_NOT_FOUND_ERROR_MESSAGE = "Error getting package info" internal const val NAME = "DdSdk" diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt index 1dfd40388..568378c25 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkNativeInitialization.kt @@ -22,6 +22,7 @@ import com.datadog.android.rum._RumInternalProxy import com.datadog.android.rum.configuration.VitalsUpdateFrequency import com.datadog.android.rum.metric.networksettled.TimeBasedInitialResourceIdentifier import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent import com.datadog.android.rum.model.ResourceEvent import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent @@ -171,6 +172,24 @@ class DdSdkNativeInitialization internal constructor( } ) + configBuilder.setErrorEventMapper( + object : EventMapper { + override fun map(event: ErrorEvent): ErrorEvent? { + // JS error tracking reports an error to RUM and, flagged with this attribute, + // to Logs at once; the native logger then forwards every error log back to + // RUM. Dropping that copy leaves one RUM error per JS error, the one the JS + // `errorEventMapper` decided on. + if (event.error.source == ErrorEvent.ErrorSource.LOGGER && + event.context?.additionalProperties + ?.containsKey(DdSdkImplementation.DD_ERROR_LOG_IS_CRASH) == true + ) { + return null + } + return event + } + } + ) + _RumInternalProxy.setTelemetryConfigurationEventMapper( configBuilder, object : EventMapper { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index 3588b57eb..0935eed05 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -24,6 +24,7 @@ import com.datadog.android.rum._RumInternalProxy import com.datadog.android.rum.configuration.VitalsUpdateFrequency import com.datadog.android.rum.metric.networksettled.TimeBasedInitialResourceIdentifier import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent import com.datadog.android.rum.model.ResourceEvent import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent @@ -33,6 +34,7 @@ import com.datadog.tools.unit.GenericAssert.Companion.assertThat import com.datadog.tools.unit.MockRumMonitor import com.datadog.tools.unit.TestUiThreadExecutor import com.datadog.tools.unit.forge.BaseConfigurator +import com.datadog.tools.unit.getFieldValue import com.datadog.tools.unit.setStaticValue import com.datadog.tools.unit.toReadableArray import com.datadog.tools.unit.toReadableJavaOnlyMap @@ -1392,7 +1394,7 @@ internal class DdSdkTest { } @Test - fun `𝕄 initialize native SDK with the on-error switches off 𝕎 initialize() {switches not set}`( + fun `𝕄 initialize native SDK with on-error switches off 𝕎 initialize() {not set}`( @Forgery configuration: DdSdkConfiguration ) { // Given @@ -1960,6 +1962,70 @@ internal class DdSdkTest { // endregion + // region error mapper + + @Test + fun `𝕄 set an error mapper that drops the logger copy of a tracked error 𝕎 initialize() {}`( + @Forgery errorEvent: ErrorEvent + ) { + // Given + val loggerCopy = errorEvent.copy( + error = errorEvent.error.copy(source = ErrorEvent.ErrorSource.LOGGER) + ) + loggerCopy.context?.additionalProperties?.put("_dd.error_log.is_crash", true) + + // When + testedBridgeSdk.initialize(fakeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + assertThat(capturedErrorEventMapper().map(loggerCopy)).isNull() + } + + @Test + fun `𝕄 set an error mapper that keeps a logger error 𝕎 initialize() {not from the JS SDK}`( + @Forgery errorEvent: ErrorEvent + ) { + // Given + val loggerError = errorEvent.copy( + error = errorEvent.error.copy(source = ErrorEvent.ErrorSource.LOGGER) + ) + + // When + testedBridgeSdk.initialize(fakeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + assertThat(capturedErrorEventMapper().map(loggerError)).isSameAs(loggerError) + } + + @Test + fun `𝕄 set an error mapper that keeps the tracked error itself 𝕎 initialize() {}`( + @Forgery errorEvent: ErrorEvent, + forge: Forge + ) { + // Given + val source = forge.anElementFrom( + ErrorEvent.ErrorSource.values().filter { it != ErrorEvent.ErrorSource.LOGGER } + ) + val trackedError = errorEvent.copy(error = errorEvent.error.copy(source = source)) + trackedError.context?.additionalProperties?.put("_dd.error_log.is_crash", true) + + // When + testedBridgeSdk.initialize(fakeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + assertThat(capturedErrorEventMapper().map(trackedError)).isSameAs(trackedError) + } + + private fun capturedErrorEventMapper(): EventMapper { + val rumConfigCaptor = argumentCaptor() + verify(mockDatadog).enableRum(rumConfigCaptor.capture()) + val featureConfiguration: Any = rumConfigCaptor.firstValue + .getFieldValue("featureConfiguration") + return featureConfiguration.getFieldValue("errorEventMapper") + } + + // endregion + // region misc @Test diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/BaseConfigurator.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/BaseConfigurator.kt index f42b3b221..78011b987 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/BaseConfigurator.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/BaseConfigurator.kt @@ -24,6 +24,7 @@ open class BaseConfigurator : forge.addFactory(TelemetryConfigurationEventForgeryFactory()) forge.addFactory(ResourceEventForgeryFactory()) forge.addFactory(ActionEventForgeryFactory()) + forge.addFactory(ErrorEventForgeryFactory()) forge.useJvmFactories() } } diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/ErrorEventForgeryFactory.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/ErrorEventForgeryFactory.kt new file mode 100644 index 000000000..c31124fe8 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/forge/ErrorEventForgeryFactory.kt @@ -0,0 +1,50 @@ +/* + * 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.tools.unit.forge + +import com.datadog.android.rum.model.ErrorEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory +import fr.xgouchet.elmyr.jvm.ext.aTimestamp +import java.util.UUID + +internal class ErrorEventForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): ErrorEvent { + return ErrorEvent( + date = forge.aTimestamp(), + error = ErrorEvent.Error( + id = forge.aNullable { getForgery().toString() }, + message = forge.anAlphabeticalString(), + source = forge.aValueFrom(ErrorEvent.ErrorSource::class.java), + stack = forge.aNullable { anAlphabeticalString() }, + isCrash = forge.aNullable { aBool() }, + type = forge.aNullable { anAlphabeticalString() } + ), + view = ErrorEvent.ErrorEventView( + id = forge.getForgery().toString(), + url = forge.aStringMatching("https://[a-z]+.[a-z]{3}/[a-z0-9_/]+"), + name = forge.aNullable { anAlphabeticalString() } + ), + application = ErrorEvent.Application(forge.getForgery().toString()), + service = forge.aNullable { anAlphabeticalString() }, + session = ErrorEvent.ErrorEventSession( + id = forge.getForgery().toString(), + type = ErrorEvent.ErrorEventSessionType.USER, + hasReplay = forge.aNullable { aBool() } + ), + source = forge.aNullable { aValueFrom(ErrorEvent.ErrorEventSource::class.java) }, + context = ErrorEvent.Context( + additionalProperties = mutableMapOf() + ), + dd = ErrorEvent.Dd( + session = forge.aNullable { ErrorEvent.DdSession(getForgery()) }, + browserSdkVersion = forge.aNullable { aStringMatching("\\d+\\.\\d+\\.\\d+") } + ) + ) + } +} diff --git a/packages/core/ios/Sources/Attributes.swift b/packages/core/ios/Sources/Attributes.swift index adf9638f5..1d9f13bda 100644 --- a/packages/core/ios/Sources/Attributes.swift +++ b/packages/core/ios/Sources/Attributes.swift @@ -108,6 +108,11 @@ internal struct InternalConfigurationAttributes { /// It flags the action as dropped by the user so it can be dropped in the action mapper. /// Expects `Bool` value. static let dropAction = "_dd.action.drop_action" + + /// Custom attribute of the log that JS error tracking sends alongside each RUM error it reports. + /// The native logger forwards error logs to RUM; the error mapper drops that copy. + /// Expects `Bool` value. + static let errorLogIsCrash = "_dd.error_log.is_crash" } /// Error messages that can be thrown to the JS SDK diff --git a/packages/core/ios/Sources/DdSdkNativeInitialization.swift b/packages/core/ios/Sources/DdSdkNativeInitialization.swift index 3b5686e14..a252fc0b4 100644 --- a/packages/core/ios/Sources/DdSdkNativeInitialization.swift +++ b/packages/core/ios/Sources/DdSdkNativeInitialization.swift @@ -48,6 +48,9 @@ public class DdSdkNativeInitialization: NSObject { return } self.setVerbosityLevel(configuration: sdkConfiguration) + // Published before the core, so that Session Replay enabled from an on-core-initialized + // listener reads the configured value. + DatadogSDKWrapper.shared.sessionReplayOnError = sdkConfiguration.sessionReplayOnError let coreConfiguration = self.buildSDKConfiguration(configuration: sdkConfiguration) DatadogSDKWrapper.shared.initialize( @@ -80,7 +83,6 @@ public class DdSdkNativeInitialization: NSObject { } func enableFeatures(sdkConfiguration: DdSdkConfiguration) { - DatadogSDKWrapper.shared.sessionReplayOnError = sdkConfiguration.sessionReplayOnError let rumConfig = buildRUMConfiguration(configuration: sdkConfiguration) DatadogSDKWrapper.shared.enableRUM(with: rumConfig) @@ -195,6 +197,17 @@ public class DdSdkNativeInitialization: NSObject { } return actionEvent }, + errorEventMapper: { errorEvent in + // JS error tracking reports an error to RUM and, flagged with this attribute, to + // Logs at once; the native logger then forwards every error log back to RUM. + // Dropping that copy leaves one RUM error per JS error, the one the JS + // `errorEventMapper` decided on. + if errorEvent.error.source == .logger, + errorEvent.context?.contextInfo[InternalConfigurationAttributes.errorLogIsCrash] != nil { + return nil + } + return errorEvent + }, onSessionStart: DdSdkSessionStartedListener.instance.rumSessionListener, customEndpoint: customRUMEndpointURL, telemetrySampleRate: (configuration.telemetrySampleRate as? NSNumber)?.floatValue ?? Float(DefaultConfiguration.telemetrySampleRate) diff --git a/packages/core/ios/Tests/DdSdkTests.swift b/packages/core/ios/Tests/DdSdkTests.swift index cb0e15929..ae90b0509 100644 --- a/packages/core/ios/Tests/DdSdkTests.swift +++ b/packages/core/ios/Tests/DdSdkTests.swift @@ -1142,16 +1142,38 @@ class DdSdkTests: XCTestCase { XCTAssertTrue(ddConfig.sessionOnError) } - func testEnableFeaturesHandsSessionReplayOnErrorToSessionReplay() { + func testInitializationPublishesSessionReplayOnErrorBeforeTheCore() { defer { DatadogSDKWrapper.shared.sessionReplayOnError = false } - DatadogSDKWrapper.shared.setCoreInstance(core: MockDatadogCore()) + // Session Replay may be enabled from an on-core-initialized listener: the switch must be + // readable by then. + var sessionReplayOnErrorWhenCoreInitialized: Bool? = nil + DatadogSDKWrapper.shared.addOnCoreInitializedListener(listener: { _ in + sessionReplayOnErrorWhenCoreInitialized = DatadogSDKWrapper.shared.sessionReplayOnError + }) + let configuration = NSDictionary.mockAny(sessionReplayOnError: true).asDdSdkConfiguration() + + DdSdkNativeInitialization().initialize(sdkConfiguration: configuration) + + XCTAssertEqual(sessionReplayOnErrorWhenCoreInitialized, true) + XCTAssertTrue(DatadogSDKWrapper.shared.sessionReplayOnError) + } + + func testErrorEventMapperDropsTheLoggerCopyOfATrackedError() throws { + let ddConfig = DdSdkNativeInitialization().buildRUMConfiguration(configuration: .mockAny()) + let errorEventMapper = try XCTUnwrap(ddConfig.errorEventMapper) - for sessionReplayOnError in [true, false] { - let configuration = NSDictionary.mockAny(sessionReplayOnError: sessionReplayOnError).asDdSdkConfiguration() + // The log that JS error tracking sends alongside the RUM error, forwarded by the logger. + let loggerCopy = RUMErrorEvent.mockRandom(source: .logger, contextInfo: ["_dd.error_log.is_crash": true]) + XCTAssertNil(errorEventMapper(loggerCopy)) - DdSdkNativeInitialization().enableFeatures(sdkConfiguration: configuration) + // A log the application sent itself. + let loggerError = RUMErrorEvent.mockRandom(source: .logger) + XCTAssertNotNil(errorEventMapper(loggerError)) - XCTAssertEqual(DatadogSDKWrapper.shared.sessionReplayOnError, sessionReplayOnError) + // The tracked error itself. + for source in [RUMErrorEvent.Error.Source.source, .console, .custom] { + let trackedError = RUMErrorEvent.mockRandom(source: source, contextInfo: ["_dd.error_log.is_crash": true]) + XCTAssertNotNil(errorEventMapper(trackedError)) } } diff --git a/packages/core/ios/Tests/RUMMocks.swift b/packages/core/ios/Tests/RUMMocks.swift index 01d0f9d0d..792df6d06 100644 --- a/packages/core/ios/Tests/RUMMocks.swift +++ b/packages/core/ios/Tests/RUMMocks.swift @@ -513,3 +513,20 @@ extension RUMActionEvent: RandomMockable { ) } } + +extension RUMErrorEvent { + static func mockRandom( + source: RUMErrorEvent.Error.Source, + contextInfo: [String: Encodable] = [:] + ) -> RUMErrorEvent { + return RUMErrorEvent( + dd: .init(session: .init(plan: .plan1, sessionPrecondition: nil)), + application: .init(id: .mockRandom()), + context: .init(contextInfo: contextInfo), + date: .mockRandom(), + error: .init(message: .mockRandom(), source: source), + session: .init(hasReplay: nil, id: .mockRandom(), type: .user), + view: .init(id: .mockRandom(), url: .mockRandom()) + ) + } +} diff --git a/packages/core/src/DdSdkReactNativeConfiguration.tsx b/packages/core/src/DdSdkReactNativeConfiguration.tsx index c6d38e784..2b34a5b10 100644 --- a/packages/core/src/DdSdkReactNativeConfiguration.tsx +++ b/packages/core/src/DdSdkReactNativeConfiguration.tsx @@ -174,9 +174,7 @@ export class DdSdkReactNativeConfiguration { * Such a session is collected in memory without uploading anything, keeping only its last * minute. If it reports an error, that minute is uploaded and the session carries on like any * collected one; if it ends without one, nothing of it is ever sent. An error dropped by - * `errorEventMapper` does not count - but on Android, an error that error tracking also logs - * through `DdLogs` reaches RUM a second time from the native logger, which counts unless - * `logEventMapper` drops that log too. Its events report a session sample rate of 0, as it + * `errorEventMapper` does not count. Its events report a session sample rate of 0, as it * stands for itself rather than for the population a rate would imply. * * Default `false`. diff --git a/packages/react-native-session-replay/README.md b/packages/react-native-session-replay/README.md index 417a3d112..ada9ce5eb 100644 --- a/packages/react-native-session-replay/README.md +++ b/packages/react-native-session-replay/README.md @@ -35,7 +35,7 @@ SessionReplay.enable({ **Note**: All configuration properties are optional and should be adjusted based on your application's needs. -To also keep the replays `replaySampleRate` leaves out, but only for sessions that report an error, set `sessionReplayOnError` on the SDK configuration (not on `SessionReplay.enable`), next to `sessionOnError`: +To also keep the replays `replaySampleRate` leaves out, but only for sessions that report an error, set `sessionReplayOnError` on the SDK configuration (not on `SessionReplay.enable`). It does not need `sessionOnError`: a session the session sample rate collects keeps its replay on error too. ```js datadogConfiguration.sessionReplayOnError = true; From 6febcbc4bcceabd913932a92db61f1b0a1710c2e Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 03:17:57 -0700 Subject: [PATCH 3/6] fix: keep the logger copy of a tracked error out of RUM in every case The log that error tracking sends alongside a RUM error carried the RUM crash flag too, so the native logger's copy of a fatal JS error reached RUM as a crash, which no error mapper can drop: a fatal error the JS `errorEventMapper` dropped still counted. The crash flag now stays on the RUM error. The marker the bridges drop that copy on is internal provenance, so `DdLogs` restores it after the application's `logEventMapper`, as it already does for the error source type. On iOS, Session Replay reads `sessionReplayOnError` only once it holds the core, which the SDK publishes after the switch. --- .../DdRumErrorTracking.test.tsx | 10 -------- packages/core/src/logs/DdLogs.ts | 5 ++++ .../core/src/logs/__tests__/DdLogs.test.ts | 24 +++++++++++++++++++ packages/core/src/rum/DdAttributes.ts | 11 +++++++++ .../instrumentation/DdRumErrorTracking.tsx | 15 ++++++++---- .../DdSessionReplayImplementation.swift | 5 ++-- 6 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx index 7f66bb58e..900f12efa 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumErrorTracking.test.tsx @@ -85,7 +85,6 @@ it('M intercept and send a RUM event W onGlobalError() {no message}', async () = 'doSomething() at ./path/to/file.js:67:3', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -127,7 +126,6 @@ it('M intercept and send a RUM event W onGlobalError() {empty stack trace}', asy '', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -170,7 +168,6 @@ it('M intercept and send a RUM event W onGlobalError() {Error object}', async () expect.stringContaining('Error: Something bad happened'), { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -217,7 +214,6 @@ it('M intercept and send a RUM event W onGlobalError() {CustomError object}', as expect.stringContaining('Error: Something bad happened'), { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -262,7 +258,6 @@ it('M intercept and send a RUM event W onGlobalError() {with source file info}', 'at ./path/to/file.js:1038:57', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -309,7 +304,6 @@ it('M intercept and send a RUM event W onGlobalError() {with component stack}', 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -356,7 +350,6 @@ it('M intercept and send a RUM event W onGlobalError() {with stack}', async () = 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -404,7 +397,6 @@ it('M intercept and send a RUM event W onGlobalError() {with stacktrace}', async 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -458,7 +450,6 @@ it('M not report error in console handler W onGlobalError() {with console report 'doSomething() at ./path/to/file.js:67:3,nestedCall() at ./path/to/file.js:1064:9,root() at ./path/to/index.js:10:1', { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } @@ -712,7 +703,6 @@ it('M intercept and send a RUM event W on error() {called from RNErrorHandler}', expect.stringContaining('Error: Something bad happened'), { '_dd.error.raw': error, - '_dd.error.is_crash': is_fatal, '_dd.error.source_type': 'react-native', '_dd.error_log.is_crash': true } diff --git a/packages/core/src/logs/DdLogs.ts b/packages/core/src/logs/DdLogs.ts index 2a77fc453..2981eb18d 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -212,6 +212,11 @@ class DdLogsWrapper implements DdLogsType { const updatedContext = validateContext(mappedEvent.context); updatedContext[DdAttributes.errorSourceType] = 'react-native'; + // Internal provenance, restored after the mapper like the source type: the native + // bridges rely on it to drop the logger copy of an error RUM already reported. + if ((context as any)[DdAttributes.errorLogIsCrash] === true) { + updatedContext[DdAttributes.errorLogIsCrash] = true; + } if (fingerprint && fingerprint !== '') { updatedContext[DdAttributes.errorFingerprint] = fingerprint; diff --git a/packages/core/src/logs/__tests__/DdLogs.test.ts b/packages/core/src/logs/__tests__/DdLogs.test.ts index 42f96bae3..49b52d971 100644 --- a/packages/core/src/logs/__tests__/DdLogs.test.ts +++ b/packages/core/src/logs/__tests__/DdLogs.test.ts @@ -31,6 +31,30 @@ describe('DdLogs', () => { DdLogs.unregisterLogEventMapper(); }); + it('keeps the error tracking marker when the mapper replaces the context', async () => { + DdLogs.registerLogEventMapper(log => ({ + ...log, + context: { replaced: true } + })); + + await DdLogs.error('message', 'TypeError', 'message', 'stack', { + '_dd.error_log.is_crash': true, + custom: 'value' + }); + + expect(NativeModules.DdLogs.errorWithError).toHaveBeenCalledWith( + 'message', + 'TypeError', + 'message', + 'stack', + { + replaced: true, + '_dd.error.source_type': 'react-native', + '_dd.error_log.is_crash': true + } + ); + }); + it('registers event mapper and maps logs', async () => { const logEventMapper: LogEventMapper = log => { return { diff --git a/packages/core/src/rum/DdAttributes.ts b/packages/core/src/rum/DdAttributes.ts index 9d92e600e..28ae9f7d9 100644 --- a/packages/core/src/rum/DdAttributes.ts +++ b/packages/core/src/rum/DdAttributes.ts @@ -18,6 +18,17 @@ export const DdAttributes = { */ errorFingerprint: '_dd.error.fingerprint', + /** + * Whether the RUM error is a crash, read by the native SDKs. + */ + errorIsCrash: '_dd.error.is_crash', + + /** + * Marks the log that error tracking sends alongside each RUM error it reports. The native + * logger forwards error logs to RUM; the bridges drop that copy, so the error counts once. + */ + errorLogIsCrash: '_dd.error_log.is_crash', + /** * Debug ID attached to a log or a RUM event. * The Debug ID establishes a unique connection between a bundle and its corresponding sourcemap. diff --git a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx index ecdc99228..6cc4aff3d 100644 --- a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx +++ b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx @@ -17,6 +17,7 @@ import { DEFAULT_ERROR_NAME } from '../../utils/errorUtils'; import { executeWithDelay } from '../../utils/jsUtils'; +import { DdAttributes } from '../DdAttributes'; import { DdRum } from '../DdRum'; import { ErrorSource } from '../types'; @@ -72,7 +73,7 @@ export class DdRumErrorTracking { const stacktrace = getErrorStackTrace(error); const errorName = getErrorName(error); this.reportError(message, ErrorSource.SOURCE, stacktrace, errorName, { - '_dd.error.is_crash': isFatal, + [DdAttributes.errorIsCrash]: isFatal, '_dd.error.raw': error }).then(async () => { DdRumErrorTracking.isInDefaultErrorHandler = true; @@ -144,6 +145,13 @@ export class DdRumErrorTracking { errorName: string, context: object = {} ): Promise<[void, void]> => { + // The crash flag stays on the RUM error. On the log it would make the copy the native + // logger forwards to RUM a crash, which no error mapper can drop. + const logContext: Record = { + ...context, + [DdAttributes.errorLogIsCrash]: true + }; + delete logContext[DdAttributes.errorIsCrash]; return Promise.all([ DdRum.addError(message, source, stacktrace, context), DdLogs.error( @@ -151,10 +159,7 @@ export class DdRumErrorTracking { errorName, message, stacktrace, - { - ...context, - '_dd.error_log.is_crash': true - }, + logContext, undefined, source ) diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index d201eed02..306016ef6 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -81,8 +81,6 @@ public class DdSessionReplayImplementation: NSObject { } } - sessionReplayConfiguration.sessionReplayOnError = DatadogSDKWrapper.shared.sessionReplayOnError - sessionReplayConfiguration.setAdditionalNodeRecorders([ SvgViewRecorder( uiManager: uiManager, @@ -96,6 +94,9 @@ public class DdSessionReplayImplementation: NSObject { ]) if let core = DatadogSDKWrapper.shared.getCoreInstance() { + // Read after the core: the SDK sets the switch before it creates the core, so a core + // that exists comes with the configured value. + sessionReplayConfiguration.sessionReplayOnError = DatadogSDKWrapper.shared.sessionReplayOnError sessionReplay.enable( with: sessionReplayConfiguration, in: core From 47cebfb1d2151c615174a0c28cdc1022ebc1c03b Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 03:35:23 -0700 Subject: [PATCH 4/6] fix: read the tracked-error log marker before the log mapper runs The marker is restored after the application's `logEventMapper`, but it was read from the context object the mapper receives, so a mapper editing the context in place could still remove it. --- packages/core/src/logs/DdLogs.ts | 11 +++- .../core/src/logs/__tests__/DdLogs.test.ts | 61 ++++++++++++------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/core/src/logs/DdLogs.ts b/packages/core/src/logs/DdLogs.ts index 2981eb18d..e610b3748 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -189,6 +189,13 @@ class DdLogsWrapper implements DdLogsType { fingerprint: string = '', source?: ErrorSource ): Promise => { + // Internal provenance, restored after the mapper like the source type: the native bridges + // rely on it to drop the logger copy of an error RUM already reported. Read before the + // mapper, which may edit the context in place. + const isTrackedErrorLog = + (context as Record)[ + DdAttributes.errorLogIsCrash + ] === true; const rawLogEvent: RawLogWithError = { message, errorKind, @@ -212,9 +219,7 @@ class DdLogsWrapper implements DdLogsType { const updatedContext = validateContext(mappedEvent.context); updatedContext[DdAttributes.errorSourceType] = 'react-native'; - // Internal provenance, restored after the mapper like the source type: the native - // bridges rely on it to drop the logger copy of an error RUM already reported. - if ((context as any)[DdAttributes.errorLogIsCrash] === true) { + if (isTrackedErrorLog) { updatedContext[DdAttributes.errorLogIsCrash] = true; } diff --git a/packages/core/src/logs/__tests__/DdLogs.test.ts b/packages/core/src/logs/__tests__/DdLogs.test.ts index 49b52d971..b3ed2e027 100644 --- a/packages/core/src/logs/__tests__/DdLogs.test.ts +++ b/packages/core/src/logs/__tests__/DdLogs.test.ts @@ -31,29 +31,46 @@ describe('DdLogs', () => { DdLogs.unregisterLogEventMapper(); }); - it('keeps the error tracking marker when the mapper replaces the context', async () => { - DdLogs.registerLogEventMapper(log => ({ - ...log, - context: { replaced: true } - })); - - await DdLogs.error('message', 'TypeError', 'message', 'stack', { - '_dd.error_log.is_crash': true, - custom: 'value' - }); - - expect(NativeModules.DdLogs.errorWithError).toHaveBeenCalledWith( - 'message', - 'TypeError', - 'message', - 'stack', - { - replaced: true, - '_dd.error.source_type': 'react-native', - '_dd.error_log.is_crash': true + it.each([ + [ + 'replaces the context', + (log: any) => ({ ...log, context: { replaced: true } }) + ], + [ + 'edits the context in place', + (log: any) => { + Object.keys(log.context).forEach(key => { + delete log.context[key]; + }); + log.context.replaced = true; + return log; } - ); - }); + ] + ])( + 'keeps the error tracking marker when the mapper %s', + async (_, logEventMapper) => { + DdLogs.registerLogEventMapper(logEventMapper); + + await DdLogs.error('message', 'TypeError', 'message', 'stack', { + '_dd.error_log.is_crash': true, + custom: 'value' + }); + + expect( + NativeModules.DdLogs.errorWithError + ).toHaveBeenCalledWith( + 'message', + 'TypeError', + 'message', + 'stack', + { + replaced: true, + '_dd.error.source_type': 'react-native', + '_dd.error_log.is_crash': true + } + ); + } + ); it('registers event mapper and maps logs', async () => { const logEventMapper: LogEventMapper = log => { From caa8fe17133f43cfacf3001239ac835aab7c99a1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:29:28 -0700 Subject: [PATCH 5/6] fix: keep the crash flag of a tracked error visible to the mappers Whether a JS error crashed the app is a fact of the error, not of its context: `DdRum.addError` keeps `_dd.error.is_crash` on the RUM error whatever the `errorEventMapper` does to the context. The log that error tracking sends alongside carries the flag for the `logEventMapper` as before; `DdLogs` strips it from the log itself, where it would make the copy the native logger forwards to RUM a crash that no mapper can drop. --- packages/core/src/logs/DdLogs.ts | 4 ++ .../core/src/logs/__tests__/DdLogs.test.ts | 41 +++++++++++++++++++ packages/core/src/rum/DdRum.ts | 9 ++++ packages/core/src/rum/__tests__/DdRum.test.ts | 30 ++++++++++++++ .../instrumentation/DdRumErrorTracking.tsx | 12 ++---- 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/core/src/logs/DdLogs.ts b/packages/core/src/logs/DdLogs.ts index e610b3748..5e071a39d 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -221,6 +221,10 @@ class DdLogsWrapper implements DdLogsType { updatedContext[DdAttributes.errorSourceType] = 'react-native'; if (isTrackedErrorLog) { updatedContext[DdAttributes.errorLogIsCrash] = true; + // The crash flag belongs to the RUM error the log goes with. Kept for the mapper, + // but on the log it would make the copy the native logger forwards to RUM a crash, + // which no error mapper can drop. + delete updatedContext[DdAttributes.errorIsCrash]; } if (fingerprint && fingerprint !== '') { diff --git a/packages/core/src/logs/__tests__/DdLogs.test.ts b/packages/core/src/logs/__tests__/DdLogs.test.ts index b3ed2e027..acfedcd49 100644 --- a/packages/core/src/logs/__tests__/DdLogs.test.ts +++ b/packages/core/src/logs/__tests__/DdLogs.test.ts @@ -72,6 +72,47 @@ describe('DdLogs', () => { } ); + it('keeps the crash flag of a tracked error off the log but visible to the mapper', async () => { + const seenByMapper: Record[] = []; + DdLogs.registerLogEventMapper(log => { + seenByMapper.push({ ...log.context }); + return log; + }); + + await DdLogs.error('message', 'TypeError', 'message', 'stack', { + '_dd.error_log.is_crash': true, + '_dd.error.is_crash': true + }); + // A log the application sends itself keeps its own context. + await DdLogs.error('own', 'TypeError', 'own', 'stack', { + '_dd.error.is_crash': true + }); + + expect(seenByMapper[0]['_dd.error.is_crash']).toBe(true); + expect(NativeModules.DdLogs.errorWithError).toHaveBeenNthCalledWith( + 1, + 'message', + 'TypeError', + 'message', + 'stack', + { + '_dd.error.source_type': 'react-native', + '_dd.error_log.is_crash': true + } + ); + expect(NativeModules.DdLogs.errorWithError).toHaveBeenNthCalledWith( + 2, + 'own', + 'TypeError', + 'own', + 'stack', + { + '_dd.error.is_crash': true, + '_dd.error.source_type': 'react-native' + } + ); + }); + it('registers event mapper and maps logs', async () => { const logEventMapper: LogEventMapper = log => { return { diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 37ae3a30e..4fecb9d02 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -252,6 +252,12 @@ class DdRumWrapper implements DdRumType { timestampMs: number = this.timeProvider.now(), fingerprint?: string ): Promise => { + // Whether the error crashed the app is a fact of the error, not of its context: kept + // whatever the mapper does to the context. Read before the mapper, which may edit the + // context in place. + const isCrash = + (context as Record)[DdAttributes.errorIsCrash] === + true; const mappedEvent = this.errorEventMapper.applyEventMapper({ message, source, @@ -269,6 +275,9 @@ class DdRumWrapper implements DdRumType { const updatedContext: any = validateContext(mappedEvent.context); updatedContext[DdAttributes.errorSourceType] = 'react-native'; + if (isCrash) { + updatedContext[DdAttributes.errorIsCrash] = true; + } const _debugId = debugId; if (_debugId) { diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index a3b9ca2b7..2625f9535 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -1170,6 +1170,36 @@ describe('DdRum', () => { ); }); + test('keeps the crash flag when the mapper replaces the context', async () => { + DdRum.registerErrorEventMapper(event => ({ + ...event, + context: { sanitized: true } + })); + try { + await DdRum.addError( + 'error', + ErrorSource.SOURCE, + 'stacktrace', + { '_dd.error.is_crash': true, secret: 'value' } + ); + } finally { + DdRum.unregisterErrorEventMapper(); + } + + expect(NativeModules.DdRum.addError).toHaveBeenCalledWith( + 'error', + ErrorSource.SOURCE, + 'stacktrace', + { + sanitized: true, + '_dd.error.is_crash': true, + '_dd.error.source_type': 'react-native' + }, + expect.anything(), + '' + ); + }); + test('uses empty context with error when context is invalid or null', async () => { const context: any = 123; await DdRum.addError( diff --git a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx index 6cc4aff3d..adce271f5 100644 --- a/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx +++ b/packages/core/src/rum/instrumentation/DdRumErrorTracking.tsx @@ -145,13 +145,6 @@ export class DdRumErrorTracking { errorName: string, context: object = {} ): Promise<[void, void]> => { - // The crash flag stays on the RUM error. On the log it would make the copy the native - // logger forwards to RUM a crash, which no error mapper can drop. - const logContext: Record = { - ...context, - [DdAttributes.errorLogIsCrash]: true - }; - delete logContext[DdAttributes.errorIsCrash]; return Promise.all([ DdRum.addError(message, source, stacktrace, context), DdLogs.error( @@ -159,7 +152,10 @@ export class DdRumErrorTracking { errorName, message, stacktrace, - logContext, + { + ...context, + [DdAttributes.errorLogIsCrash]: true + }, undefined, source ) From f6498c8e2cde710bdf91a1ffe42b1043be51b73c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:40:09 -0700 Subject: [PATCH 6/6] fix: accept a null context when reading the crash flag of an error `DdRum.addError` read the crash flag off the context before validating it, so a null context threw instead of being treated as empty. Also covers the on-error switches set one at a time. --- .../com/datadog/reactnative/DdSdkTest.kt | 25 +++++++++++++++++++ .../src/__tests__/DdSdkReactNative.test.tsx | 25 +++++++++++++++++++ packages/core/src/logs/DdLogs.ts | 1 + packages/core/src/rum/DdRum.ts | 3 ++- packages/core/src/rum/__tests__/DdRum.test.ts | 18 +++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index 0935eed05..ef2a6e979 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -1393,6 +1393,31 @@ internal class DdSdkTest { } } + @Test + fun `𝕄 initialize native SDK 𝕎 initialize() {one on-error switch}`( + @Forgery configuration: DdSdkConfiguration, + forge: Forge + ) { + // Given + val sessionOnError = forge.aBool() + val bridgeConfiguration = configuration.copy( + sessionOnError = sessionOnError, + sessionReplayOnError = !sessionOnError + ) + val rumConfigCaptor = argumentCaptor() + + // When + testedBridgeSdk.initialize(bridgeConfiguration.toReadableJavaOnlyMap(), mockPromise) + + // Then + verify(mockDatadog).enableRum(rumConfigCaptor.capture()) + assertThat(rumConfigCaptor.firstValue) + .hasField("featureConfiguration") { + it.hasFieldEqualTo("sessionOnError", sessionOnError) + it.hasFieldEqualTo("sessionReplayOnError", !sessionOnError) + } + } + @Test fun `𝕄 initialize native SDK with on-error switches off 𝕎 initialize() {not set}`( @Forgery configuration: DdSdkConfiguration diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 0f76ae6d1..a48d9ddc9 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -528,6 +528,31 @@ describe('DdSdkReactNative', () => { ); }); + it.each([ + [true, false], + [false, true] + ])( + 'initializes with sessionOnError %s and sessionReplayOnError %s', + async (sessionOnError, sessionReplayOnError) => { + const configuration = new DdSdkReactNativeConfiguration( + '2', + 'env', + '1' + ); + configuration.sessionOnError = sessionOnError; + configuration.sessionReplayOnError = sessionReplayOnError; + + await DdSdkReactNative.initialize(configuration); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + sessionOnError, + sessionReplayOnError + }) + ); + } + ); + it('initializes with sessionOnError and sessionReplayOnError when they are specified', async () => { // GIVEN const configuration = new DdSdkReactNativeConfiguration( diff --git a/packages/core/src/logs/DdLogs.ts b/packages/core/src/logs/DdLogs.ts index 5e071a39d..04ed604cb 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -193,6 +193,7 @@ class DdLogsWrapper implements DdLogsType { // rely on it to drop the logger copy of an error RUM already reported. Read before the // mapper, which may edit the context in place. const isTrackedErrorLog = + !!context && (context as Record)[ DdAttributes.errorLogIsCrash ] === true; diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 4fecb9d02..bf809ca60 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -256,8 +256,9 @@ class DdRumWrapper implements DdRumType { // whatever the mapper does to the context. Read before the mapper, which may edit the // context in place. const isCrash = + !!context && (context as Record)[DdAttributes.errorIsCrash] === - true; + true; const mappedEvent = this.errorEventMapper.applyEventMapper({ message, source, diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 2625f9535..7a3e25016 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -1170,6 +1170,24 @@ describe('DdRum', () => { ); }); + test('accepts a null context', async () => { + await DdRum.addError( + 'error', + ErrorSource.CUSTOM, + 'stacktrace', + null as any + ); + + expect(NativeModules.DdRum.addError).toHaveBeenCalledWith( + 'error', + ErrorSource.CUSTOM, + 'stacktrace', + { '_dd.error.source_type': 'react-native' }, + expect.anything(), + '' + ); + }); + test('keeps the crash flag when the mapper replaces the context', async () => { DdRum.registerErrorEventMapper(event => ({ ...event,