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/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 c7ebaaf1a..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 @@ -117,6 +118,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) @@ -169,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/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..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 @@ -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 @@ -1366,6 +1368,79 @@ 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 𝕎 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 + ) { + // 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 @@ -1912,6 +1987,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/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/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/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/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/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/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/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..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( @@ -169,7 +172,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, @@ -194,10 +197,23 @@ 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) ) + 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..ae90b0509 100644 --- a/packages/core/ios/Tests/DdSdkTests.swift +++ b/packages/core/ios/Tests/DdSdkTests.swift @@ -1119,6 +1119,64 @@ 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 testInitializationPublishesSessionReplayOnErrorBeforeTheCore() { + defer { DatadogSDKWrapper.shared.sessionReplayOnError = false } + // 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) + + // 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)) + + // A log the application sent itself. + let loggerError = RUMErrorEvent.mockRandom(source: .logger) + XCTAssertNotNil(errorEventMapper(loggerError)) + + // 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)) + } + } + func testConfigurationTelemetryOverride() throws { let core = MockDatadogCore() let configuration: DdSdkConfiguration = .mockAny( @@ -1379,7 +1437,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 +1472,9 @@ extension DdSdkConfiguration { appHangThreshold: appHangThreshold, trackWatchdogTerminations: trackWatchdogTerminations, batchProcessingLevel: batchProcessingLevel.asBatchProcessingLevel(), - initialResourceThreshold: initialResourceThreshold + initialResourceThreshold: initialResourceThreshold, + sessionOnError: sessionOnError, + sessionReplayOnError: sessionReplayOnError ) } } @@ -1444,7 +1506,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 +1537,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/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/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..2b34a5b10 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,26 @@ 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. 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 +502,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..a48d9ddc9 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -507,6 +507,75 @@ 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.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( + '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/__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..04ed604cb 100644 --- a/packages/core/src/logs/DdLogs.ts +++ b/packages/core/src/logs/DdLogs.ts @@ -189,6 +189,14 @@ 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 && + (context as Record)[ + DdAttributes.errorLogIsCrash + ] === true; const rawLogEvent: RawLogWithError = { message, errorKind, @@ -212,6 +220,13 @@ class DdLogsWrapper implements DdLogsType { const updatedContext = validateContext(mappedEvent.context); 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 !== '') { 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..acfedcd49 100644 --- a/packages/core/src/logs/__tests__/DdLogs.test.ts +++ b/packages/core/src/logs/__tests__/DdLogs.test.ts @@ -31,6 +31,88 @@ describe('DdLogs', () => { DdLogs.unregisterLogEventMapper(); }); + 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('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/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/DdRum.ts b/packages/core/src/rum/DdRum.ts index 37ae3a30e..bf809ca60 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -252,6 +252,13 @@ 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 && + (context as Record)[DdAttributes.errorIsCrash] === + true; const mappedEvent = this.errorEventMapper.applyEventMapper({ message, source, @@ -269,6 +276,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..7a3e25016 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -1170,6 +1170,54 @@ 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, + 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 ecdc99228..adce271f5 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; @@ -153,7 +154,7 @@ export class DdRumErrorTracking { stacktrace, { ...context, - '_dd.error_log.is_crash': true + [DdAttributes.errorLogIsCrash]: true }, undefined, source 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..ada9ce5eb 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`). It does not need `sessionOnError`: a session the session sample rate collects keeps its replay on error too. + +```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..306016ef6 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -94,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 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,