From d7e838e3906a580cb49094db88df625fbca161ac Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Thu, 1 Oct 2026 12:53:46 +0200 Subject: [PATCH] feat: add nativeIosResourceTracking RUM option --- .../core/datadog-configuration.schema.json | 4 + .../core/ios/Sources/DdSdkConfiguration.swift | 7 ++ .../Sources/DdSdkNativeInitialization.swift | 24 +++-- .../ios/Sources/RNDdSdkConfiguration.swift | 5 + .../DdSdkNativeInitializationTests.swift | 2 + packages/core/ios/Tests/DdSdkTests.swift | 95 +++++++++++++++++++ .../Fixtures/complete-configuration.json | 1 + .../ios/Tests/RNDdSdkConfigurationTests.swift | 33 +++++++ .../src/__tests__/DdSdkReactNative.test.tsx | 43 +++++++++ .../DdSdkReactNativeConfiguration.test.ts | 4 + .../core/src/config/FileBasedConfiguration.ts | 4 +- .../src/config/FileBasedConfiguration.type.ts | 1 + .../__tests__/FileBasedConfiguration.test.ts | 38 ++++++++ .../configuration-all-fields.json | 1 + .../async/AutoInstrumentationConfiguration.ts | 5 + .../PartialInitializationConfiguration.ts | 1 + .../src/config/features/RumConfiguration.ts | 5 + .../config/features/RumConfiguration.type.ts | 8 ++ .../config/features/RumConfigurationNative.ts | 1 + .../__tests__/initialization.test.tsx | 82 ++++++++++++++++ 20 files changed, 356 insertions(+), 8 deletions(-) diff --git a/packages/core/datadog-configuration.schema.json b/packages/core/datadog-configuration.schema.json index 992e72f3e..4d83a3de8 100644 --- a/packages/core/datadog-configuration.schema.json +++ b/packages/core/datadog-configuration.schema.json @@ -211,6 +211,10 @@ "description": "Enables native interaction tracking.", "type": "boolean" }, + "nativeIosResourceTracking": { + "description": "Controls automatic native network (URLSession) resource tracking. iOS only: has no effect on Android. If not set (default): legacy behavior, enables iOS network tracking if `firstPartyHosts` is defined. `true`: enables iOS network tracking (with trace headers for `firstPartyHosts`, if defined). `false`: disables iOS network tracking.", + "type": "boolean" + }, "nativeCrashReportEnabled": { "description": "Enables crash reporting for native platforms (iOS, Android). Default `false`.", "type": "boolean" diff --git a/packages/core/ios/Sources/DdSdkConfiguration.swift b/packages/core/ios/Sources/DdSdkConfiguration.swift index fd8e8f3e1..2f1a056df 100644 --- a/packages/core/ios/Sources/DdSdkConfiguration.swift +++ b/packages/core/ios/Sources/DdSdkConfiguration.swift @@ -95,6 +95,10 @@ public class DdSdkConfiguration: NSObject { /// - nativeLongTaskThresholdMs: The threshold for reporting native long tasks in milliseconds. /// - nativeViewTracking: Enables tracking of native iOS/Android UI views. /// - nativeInteractionTracking: Enables tracking of native UI interactions. +/// - nativeIosResourceTracking: Controls automatic URLSession resource tracking (iOS only). +/// `nil` (default): legacy behavior, tracking enabled iff `firstPartyHosts` is non-nil. +/// `true`: tracking always enabled (trace headers for `firstPartyHosts`, if non-nil). +/// `false`: tracking disabled. /// - firstPartyHosts: List of backend hosts considered first-party for network tracing. /// - appHangThreshold: Threshold in seconds for reporting non-fatal app hangs (iOS only). /// - trackWatchdogTerminations: Whether the SDK should track application terminations @@ -115,6 +119,7 @@ public class RumConfiguration: NSObject { public var nativeLongTaskThresholdMs: Double? = nil public var nativeViewTracking: Bool? = nil public var nativeInteractionTracking: Bool? = nil + public var nativeIosResourceTracking: Bool? = nil public var firstPartyHosts: [String: Set]? = nil public var appHangThreshold: Double? = nil public var trackWatchdogTerminations: Bool @@ -135,6 +140,7 @@ public class RumConfiguration: NSObject { nativeLongTaskThresholdMs: Double? = nil, nativeViewTracking: Bool?, nativeInteractionTracking: Bool?, + nativeIosResourceTracking: Bool? = nil, firstPartyHosts: [String: Set]?, appHangThreshold: Double?, trackWatchdogTerminations: Bool, @@ -154,6 +160,7 @@ public class RumConfiguration: NSObject { self.nativeLongTaskThresholdMs = nativeLongTaskThresholdMs self.nativeViewTracking = nativeViewTracking self.nativeInteractionTracking = nativeInteractionTracking + self.nativeIosResourceTracking = nativeIosResourceTracking self.firstPartyHosts = firstPartyHosts self.appHangThreshold = appHangThreshold self.trackWatchdogTerminations = trackWatchdogTerminations diff --git a/packages/core/ios/Sources/DdSdkNativeInitialization.swift b/packages/core/ios/Sources/DdSdkNativeInitialization.swift index c46ddd145..4199af491 100644 --- a/packages/core/ios/Sources/DdSdkNativeInitialization.swift +++ b/packages/core/ios/Sources/DdSdkNativeInitialization.swift @@ -168,15 +168,25 @@ public class DdSdkNativeInitialization: NSObject { uiKitActionsPredicate = DefaultUIKitRUMActionsPredicate() } - var urlSessionTracking: RUM.Configuration.URLSessionTracking? = nil + // `nativeIosResourceTracking` (iOS only): + // - nil (default): legacy behavior, tracking enabled iff `firstPartyHosts` is non-nil. + // - true: tracking always enabled (trace headers only if `firstPartyHosts` is non-nil). + // - false: tracking disabled. + var firstPartyHostsTracing: RUM.Configuration.URLSessionTracking.FirstPartyHostsTracing? = nil if let firstPartyHosts = rumConfig.firstPartyHosts { + firstPartyHostsTracing = .traceWithHeaders( + hostsWithHeaders: firstPartyHosts, + sampleRate: Float( + configuration.rumConfiguration?.resourceTraceSampleRate + ?? DefaultConfiguration.resourceTraceSampleRate) + ) + } + let shouldTrackURLSession = rumConfig.nativeIosResourceTracking ?? (rumConfig.firstPartyHosts != nil) + + var urlSessionTracking: RUM.Configuration.URLSessionTracking? = nil + if shouldTrackURLSession { urlSessionTracking = RUM.Configuration.URLSessionTracking( - firstPartyHostsTracing: .traceWithHeaders( - hostsWithHeaders: firstPartyHosts, - sampleRate: Float( - configuration.rumConfiguration?.resourceTraceSampleRate - ?? DefaultConfiguration.resourceTraceSampleRate) - ), + firstPartyHostsTracing: firstPartyHostsTracing, resourceAttributesProvider: { request, _, _, _ in let trackedBy = request.value(forHTTPHeaderField: InternalConfigurationAttributes.trackedByHeaderKey) if trackedBy == InternalConfigurationAttributes.trackedByHeaderValue { diff --git a/packages/core/ios/Sources/RNDdSdkConfiguration.swift b/packages/core/ios/Sources/RNDdSdkConfiguration.swift index bd84625b3..0ab0cfd21 100644 --- a/packages/core/ios/Sources/RNDdSdkConfiguration.swift +++ b/packages/core/ios/Sources/RNDdSdkConfiguration.swift @@ -60,6 +60,8 @@ extension NSDictionary { let nativeLongTaskThresholdMs = rumDict["nativeLongTaskThresholdMs"] as? Double let nativeViewTracking = rumDict["nativeViewTracking"] as? Bool let nativeInteractionTracking = rumDict["nativeInteractionTracking"] as? Bool + // No default on purpose: nil means legacy URLSession tracking behavior + let nativeIosResourceTracking = rumDict["nativeIosResourceTracking"] as? Bool let firstPartyHostsArray = rumDict["firstPartyHosts"] as? NSArray let firstPartyHosts = firstPartyHostsArray?.asFirstPartyHosts() @@ -89,6 +91,7 @@ extension NSDictionary { nativeViewTracking: nativeViewTracking ?? DefaultConfiguration.nativeViewTracking, nativeInteractionTracking: nativeInteractionTracking ?? DefaultConfiguration.nativeInteractionTracking, + nativeIosResourceTracking: nativeIosResourceTracking, firstPartyHosts: firstPartyHosts, appHangThreshold: appHangThreshold, trackWatchdogTerminations: trackWatchdogTerminations @@ -378,6 +381,8 @@ extension Dictionary where Key == String, Value == AnyObject { ?? DefaultConfiguration.nativeViewTracking, nativeInteractionTracking: rum["nativeInteractionTracking"] as? Bool ?? DefaultConfiguration.nativeInteractionTracking, + // No default on purpose: nil means legacy URLSession tracking behavior + nativeIosResourceTracking: rum["nativeIosResourceTracking"] as? Bool, firstPartyHosts: firstPartyHosts, appHangThreshold: rum["appHangThreshold"] as? Double, trackWatchdogTerminations: rum["trackWatchdogTerminations"] as? Bool diff --git a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift index cb6a6c500..712bb00d3 100644 --- a/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift +++ b/packages/core/ios/Tests/DdSdkNativeInitializationTests.swift @@ -56,6 +56,7 @@ class DdSdkNativeInitializationTests: XCTestCase { XCTAssertEqual(configuration?.rumConfiguration?.nativeLongTaskThresholdMs, 333.0) XCTAssertEqual(configuration?.rumConfiguration?.nativeViewTracking, true) XCTAssertEqual(configuration?.rumConfiguration?.nativeInteractionTracking, true) + XCTAssertEqual(configuration?.rumConfiguration?.nativeIosResourceTracking, true) XCTAssertEqual(configuration?.verbosity, "WARN") XCTAssertEqual(configuration?.service, "my.app") XCTAssertEqual(configuration?.proxyConfiguration?["HTTPEnable"] as? Int, 1) @@ -98,6 +99,7 @@ class DdSdkNativeInitializationTests: XCTestCase { XCTAssertEqual(configuration?.rumConfiguration?.nativeLongTaskThresholdMs, 200.0) XCTAssertEqual(configuration?.rumConfiguration?.nativeViewTracking, false) XCTAssertEqual(configuration?.rumConfiguration?.nativeInteractionTracking, false) + XCTAssertNil(configuration?.rumConfiguration?.nativeIosResourceTracking) XCTAssertEqual(configuration?.verbosity, nil) XCTAssertEqual(configuration?.service, nil) XCTAssertNil(configuration?.proxyConfiguration) diff --git a/packages/core/ios/Tests/DdSdkTests.swift b/packages/core/ios/Tests/DdSdkTests.swift index bc13c4f98..f47300ff6 100644 --- a/packages/core/ios/Tests/DdSdkTests.swift +++ b/packages/core/ios/Tests/DdSdkTests.swift @@ -993,6 +993,101 @@ class DdSdkTests: XCTestCase { XCTAssertEqual(actualTraceContextInjection, .sampled) } + // MARK: - nativeIosResourceTracking (URLSession tracking tri-state) + + private func buildURLSessionTracking( + nativeIosResourceTracking: Bool?, + firstPartyHosts: [String: Set]? + ) -> RUM.Configuration.URLSessionTracking? { + let rumConfiguration: RumConfiguration = makeDefaultRumConfiguration() + rumConfiguration.resourceTraceSampleRate = 66 + rumConfiguration.nativeIosResourceTracking = nativeIosResourceTracking + rumConfiguration.firstPartyHosts = firstPartyHosts + let configuration: DdSdkConfiguration = .mockAny(rumConfiguration: rumConfiguration) + return DdSdkNativeInitialization().buildRumConfiguration( + configuration: configuration + ).urlSessionTracking + } + + /// Returns the hosts of `.traceWithHeaders`, or `nil` when tracing is not configured. + private func tracedHosts( + _ tracking: RUM.Configuration.URLSessionTracking? + ) -> [String: Set]? { + switch tracking?.firstPartyHostsTracing { + case let .traceWithHeaders(hostsWithHeaders, samplingRate, _): + XCTAssertEqual(samplingRate, 66) + return hostsWithHeaders + case .trace, .none: + return nil + } + } + + private let nonEmptyFirstPartyHosts: [String: Set] = [ + "example.com": [.datadog, .tracecontext] + ] + + // Legacy (nil) behavior: must be unchanged + + func testNativeIosResourceTrackingNilWithEmptyFirstPartyHostsEnablesTracking() { + // Regression: JS default `firstPartyHosts: []` => URLSession tracking ON + let tracking = buildURLSessionTracking(nativeIosResourceTracking: nil, firstPartyHosts: [:]) + XCTAssertNotNil(tracking) + XCTAssertNotNil(tracking?.resourceAttributesProvider) + XCTAssertEqual(tracedHosts(tracking), [:]) + } + + func testNativeIosResourceTrackingNilWithNilFirstPartyHostsDisablesTracking() { + let tracking = buildURLSessionTracking(nativeIosResourceTracking: nil, firstPartyHosts: nil) + XCTAssertNil(tracking) + } + + func testNativeIosResourceTrackingNilWithFirstPartyHostsTracesHosts() { + let tracking = buildURLSessionTracking( + nativeIosResourceTracking: nil, firstPartyHosts: nonEmptyFirstPartyHosts) + XCTAssertNotNil(tracking) + XCTAssertNotNil(tracking?.resourceAttributesProvider) + XCTAssertEqual(tracedHosts(tracking), nonEmptyFirstPartyHosts) + } + + // Explicit opt-in + + func testNativeIosResourceTrackingTrueWithNilFirstPartyHostsEnablesTrackingWithoutTracing() { + let tracking = buildURLSessionTracking(nativeIosResourceTracking: true, firstPartyHosts: nil) + XCTAssertNotNil(tracking) + XCTAssertNotNil(tracking?.resourceAttributesProvider) + XCTAssertNil(tracking?.firstPartyHostsTracing) + } + + func testNativeIosResourceTrackingTrueWithEmptyFirstPartyHostsEnablesTracking() { + let tracking = buildURLSessionTracking(nativeIosResourceTracking: true, firstPartyHosts: [:]) + XCTAssertNotNil(tracking) + XCTAssertNotNil(tracking?.resourceAttributesProvider) + XCTAssertEqual(tracedHosts(tracking), [:]) + } + + func testNativeIosResourceTrackingTrueWithFirstPartyHostsTracesHosts() { + let tracking = buildURLSessionTracking( + nativeIosResourceTracking: true, firstPartyHosts: nonEmptyFirstPartyHosts) + XCTAssertNotNil(tracking) + XCTAssertNotNil(tracking?.resourceAttributesProvider) + XCTAssertEqual(tracedHosts(tracking), nonEmptyFirstPartyHosts) + } + + // Explicit opt-out + + func testNativeIosResourceTrackingFalseWithNilFirstPartyHostsDisablesTracking() { + XCTAssertNil(buildURLSessionTracking(nativeIosResourceTracking: false, firstPartyHosts: nil)) + } + + func testNativeIosResourceTrackingFalseWithEmptyFirstPartyHostsDisablesTracking() { + XCTAssertNil(buildURLSessionTracking(nativeIosResourceTracking: false, firstPartyHosts: [:])) + } + + func testNativeIosResourceTrackingFalseWithFirstPartyHostsDisablesTracking() { + XCTAssertNil(buildURLSessionTracking( + nativeIosResourceTracking: false, firstPartyHosts: nonEmptyFirstPartyHosts)) + } + func testBuildTelemetrySampleRate() { let rumConfiguration: RumConfiguration = makeDefaultRumConfiguration() rumConfiguration.telemetrySampleRate = 42.0 diff --git a/packages/core/ios/Tests/Fixtures/complete-configuration.json b/packages/core/ios/Tests/Fixtures/complete-configuration.json index d2648a289..d41df0a6b 100644 --- a/packages/core/ios/Tests/Fixtures/complete-configuration.json +++ b/packages/core/ios/Tests/Fixtures/complete-configuration.json @@ -21,6 +21,7 @@ "nativeCrashReportEnabled": true, "nativeLongTaskThresholdMs": 333, "nativeInteractionTracking": true, + "nativeIosResourceTracking": true, "actionNameAttribute": "action-name-attr", "customEndpoint": "https://rum.example.com", "vitalsUpdateFrequency": "NEVER", diff --git a/packages/core/ios/Tests/RNDdSdkConfigurationTests.swift b/packages/core/ios/Tests/RNDdSdkConfigurationTests.swift index 00e73e399..027aaf2e0 100644 --- a/packages/core/ios/Tests/RNDdSdkConfigurationTests.swift +++ b/packages/core/ios/Tests/RNDdSdkConfigurationTests.swift @@ -59,6 +59,39 @@ class RNDdSdkConfigurationTests: XCTestCase { XCTAssertEqual(proxy?["SOCKSPort"] as? NSNumber, 99) } + private func makeConfigurationDict(nativeIosResourceTracking: Any?) -> NSDictionary { + var rumDict: [String: Any] = ["applicationId": "app-id"] + if let value = nativeIosResourceTracking { + rumDict["nativeIosResourceTracking"] = value + } + return [ + "clientToken": "client-token", + "env": "env", + "rumConfiguration": rumDict as NSDictionary + ] + } + + func testBuildNativeIosResourceTrackingAbsentIsNil() { + let configuration = makeConfigurationDict(nativeIosResourceTracking: nil).asDdSdkConfiguration() + XCTAssertNotNil(configuration.rumConfiguration) + XCTAssertNil(configuration.rumConfiguration?.nativeIosResourceTracking) + } + + func testBuildNativeIosResourceTrackingNullIsNil() { + let configuration = makeConfigurationDict(nativeIosResourceTracking: NSNull()).asDdSdkConfiguration() + XCTAssertNil(configuration.rumConfiguration?.nativeIosResourceTracking) + } + + func testBuildNativeIosResourceTrackingTrue() { + let configuration = makeConfigurationDict(nativeIosResourceTracking: true).asDdSdkConfiguration() + XCTAssertEqual(configuration.rumConfiguration?.nativeIosResourceTracking, true) + } + + func testBuildNativeIosResourceTrackingFalse() { + let configuration = makeConfigurationDict(nativeIosResourceTracking: false).asDdSdkConfiguration() + XCTAssertEqual(configuration.rumConfiguration?.nativeIosResourceTracking, false) + } + func testBuildFirstPartyHosts() { let firstPartyHosts = [ ["match": "example.com", "propagatorTypes": ["datadog", "b3"]], diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 01cd44371..de6013c1a 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -428,6 +428,49 @@ describe('DdSdkReactNative', () => { ).toBe(false); }); + it('initializes with nativeIosResourceTracking undefined when not specified', async () => { + // GIVEN + const configuration = new CoreConfiguration('2', 'env'); + configuration.rumConfiguration = new RumConfiguration('1'); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + const ddSdkConfiguration = NativeModules.DdSdk.initialize.mock + .calls[0][0] as DdSdkNativeConfiguration; + expect(ddSdkConfiguration.rumConfiguration).toBeDefined(); + expect( + ddSdkConfiguration.rumConfiguration?.nativeIosResourceTracking + ).toBeUndefined(); + }); + + it.each([true, false])( + 'initializes with nativeIosResourceTracking %s when it is specified', + async value => { + // GIVEN + const configuration = new CoreConfiguration('2', 'env'); + configuration.rumConfiguration = new RumConfiguration( + '1', + false, + false, + false, + { nativeIosResourceTracking: value } + ); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + const ddSdkConfiguration = NativeModules.DdSdk.initialize.mock + .calls[0][0] as DdSdkNativeConfiguration; + expect( + ddSdkConfiguration.rumConfiguration + ?.nativeIosResourceTracking + ).toBe(value); + } + ); + it('initializes with bundleLogsWithTraces false when it is specified', async () => { // GIVEN const fakeAppId = '1'; diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 7f8a6e231..ec6ecd777 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -65,6 +65,7 @@ describe('DdSdkReactNativeConfiguration', () => { "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, "nativeInteractionTracking": false, + "nativeIosResourceTracking": undefined, "nativeLongTaskThresholdMs": 200, "nativeViewTracking": false, "resourceEventMapper": null, @@ -134,6 +135,7 @@ describe('DdSdkReactNativeConfiguration', () => { nativeLongTaskThresholdMs: 345, nativeViewTracking: true, nativeInteractionTracking: true, + nativeIosResourceTracking: true, longTaskThresholdMs: 567, trackFrustrations: true, trackNonFatalAnrs: true, @@ -210,6 +212,7 @@ describe('DdSdkReactNativeConfiguration', () => { "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, "nativeInteractionTracking": true, + "nativeIosResourceTracking": true, "nativeLongTaskThresholdMs": 345, "nativeViewTracking": true, "resourceEventMapper": [Function], @@ -314,6 +317,7 @@ describe('DdSdkReactNativeConfiguration', () => { "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, "nativeInteractionTracking": false, + "nativeIosResourceTracking": undefined, "nativeLongTaskThresholdMs": 0, "nativeViewTracking": false, "resourceEventMapper": null, diff --git a/packages/core/src/config/FileBasedConfiguration.ts b/packages/core/src/config/FileBasedConfiguration.ts index cd89c7c07..8b9d20e52 100644 --- a/packages/core/src/config/FileBasedConfiguration.ts +++ b/packages/core/src/config/FileBasedConfiguration.ts @@ -208,7 +208,9 @@ export const getJSONConfiguration = ( telemetrySampleRate: configuration.rumConfiguration.telemetrySampleRate, nativeInteractionTracking: - configuration.rumConfiguration.nativeInteractionTracking + configuration.rumConfiguration.nativeInteractionTracking, + nativeIosResourceTracking: + configuration.rumConfiguration.nativeIosResourceTracking } }), ...(configuration.traceConfiguration !== undefined && { diff --git a/packages/core/src/config/FileBasedConfiguration.type.ts b/packages/core/src/config/FileBasedConfiguration.type.ts index b248fe2f4..d382e5dfa 100644 --- a/packages/core/src/config/FileBasedConfiguration.type.ts +++ b/packages/core/src/config/FileBasedConfiguration.type.ts @@ -46,6 +46,7 @@ export interface JsonConfiguration extends CoreConfigurationOptions { nativeLongTaskThresholdMs?: number; nativeViewTracking?: boolean; nativeInteractionTracking?: boolean; + nativeIosResourceTracking?: boolean; customEndpoint?: string; sessionSampleRate?: number; resourceTraceSampleRate?: number; diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 1b801d8c5..2240a490e 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -59,6 +59,7 @@ describe('FileBasedConfiguration', () => { "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, "nativeInteractionTracking": true, + "nativeIosResourceTracking": true, "nativeLongTaskThresholdMs": 789, "nativeViewTracking": true, "resourceEventMapper": null, @@ -172,6 +173,7 @@ describe('FileBasedConfiguration', () => { "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, "nativeInteractionTracking": false, + "nativeIosResourceTracking": undefined, "nativeLongTaskThresholdMs": 200, "nativeViewTracking": false, "resourceEventMapper": null, @@ -237,6 +239,7 @@ describe('FileBasedConfiguration', () => { "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, "nativeInteractionTracking": false, + "nativeIosResourceTracking": undefined, "nativeLongTaskThresholdMs": 200, "nativeViewTracking": false, "resourceEventMapper": null, @@ -267,6 +270,41 @@ describe('FileBasedConfiguration', () => { `); }); + it('leaves nativeIosResourceTracking undefined when not set', () => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id' + } + } + }); + expect(config.rumConfiguration).toHaveProperty( + 'nativeIosResourceTracking', + undefined + ); + }); + + it.each([true, false])( + 'passes nativeIosResourceTracking through when set to %s', + value => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id', + nativeIosResourceTracking: value + } + } + }); + expect(config.rumConfiguration?.nativeIosResourceTracking).toBe( + value + ); + } + ); + it('applies event mappers to configuration when provided', () => { const actionEventMapper = () => null; const errorEventMapper = () => null; diff --git a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json index 6a4f7623a..bfbda7a30 100644 --- a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json +++ b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json @@ -28,6 +28,7 @@ "initialResourceThreshold": 456, "nativeViewTracking": true, "nativeInteractionTracking": true, + "nativeIosResourceTracking": true, "nativeLongTaskThresholdMs": 789, "nativeCrashReportEnabled": true, "trackBackgroundEvents": true, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index b049e10bd..2a1c34184 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -28,6 +28,7 @@ export type AutoInstrumentationConfiguration = { readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; + readonly nativeIosResourceTracking?: boolean; readonly actionEventMapper?: ActionEventMapper | null; readonly errorEventMapper?: ErrorEventMapper | null; readonly resourceEventMapper?: ResourceEventMapper | null; @@ -53,6 +54,7 @@ export type AutoInstrumentationParameters = { readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; + readonly nativeIosResourceTracking?: boolean; readonly actionEventMapper: ActionEventMapper | null; readonly errorEventMapper: ErrorEventMapper | null; readonly resourceEventMapper: ResourceEventMapper | null; @@ -117,6 +119,9 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( nativeViewTracking: features.rumConfiguration.nativeViewTracking ?? RUM_DEFAULTS.nativeViewTracking, + // No default on purpose: undefined means legacy iOS behavior + nativeIosResourceTracking: + features.rumConfiguration.nativeIosResourceTracking, firstPartyHosts: features.rumConfiguration.firstPartyHosts || RUM_DEFAULTS.getFirstPartyHosts() diff --git a/packages/core/src/config/async/PartialInitializationConfiguration.ts b/packages/core/src/config/async/PartialInitializationConfiguration.ts index 8be039ae0..44ae2ffa6 100644 --- a/packages/core/src/config/async/PartialInitializationConfiguration.ts +++ b/packages/core/src/config/async/PartialInitializationConfiguration.ts @@ -36,6 +36,7 @@ export type PartialInitializationConfiguration = { readonly nativeCrashReportEnabled?: boolean; readonly nativeViewTracking?: boolean; readonly nativeInteractionTracking?: boolean; + readonly nativeIosResourceTracking?: boolean; readonly longTaskThresholdMs?: number | false; readonly vitalsUpdateFrequency?: VitalsUpdateFrequency; readonly trackFrustrations?: boolean; diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index 9d6e6b965..af723d21a 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -26,6 +26,7 @@ const DEFAULTS = { nativeCrashReportEnabled: false, nativeInteractionTracking: false, nativeLongTaskThresholdMs: 200, + nativeIosResourceTracking: undefined, nativeViewTracking: false, resourceEventMapper: null, resourceTraceSampleRate: 100.0, @@ -85,6 +86,10 @@ export class RumConfiguration implements RumConfigurationType { public nativeLongTaskThresholdMs: number = DEFAULTS.nativeLongTaskThresholdMs; + // Native Resource Tracking (iOS only); undefined means legacy behavior + public nativeIosResourceTracking?: boolean = + DEFAULTS.nativeIosResourceTracking; + // Native View Tracking enabled public nativeViewTracking: boolean = DEFAULTS.nativeViewTracking; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index 40509aaa6..607f507e9 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -109,6 +109,14 @@ export interface RumConfigurationOptions { */ nativeViewTracking?: boolean; + /** + * Controls automatic native network (URLSession) resource tracking. iOS only: has no effect on Android. + * - `undefined` (default): legacy behavior, enables iOS network tracking if `firstPartyHosts` is defined. + * - `true`: enables iOS network tracking (with trace headers for `firstPartyHosts`, if defined). + * - `false`: disables iOS network tracking. + */ + nativeIosResourceTracking?: boolean; + /** * Custom mapper to transform RUM resource events. */ diff --git a/packages/core/src/config/features/RumConfigurationNative.ts b/packages/core/src/config/features/RumConfigurationNative.ts index 155f086e9..71a3e0eda 100644 --- a/packages/core/src/config/features/RumConfigurationNative.ts +++ b/packages/core/src/config/features/RumConfigurationNative.ts @@ -15,6 +15,7 @@ export type RumNativeConfiguration = { readonly nativeLongTaskThresholdMs: number; readonly nativeViewTracking: boolean; readonly nativeInteractionTracking: boolean; + readonly nativeIosResourceTracking: boolean | undefined; readonly trackNonFatalAnrs: boolean | undefined; readonly appHangThreshold: number | undefined; readonly trackWatchdogTerminations: boolean | undefined; diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 224e49f0a..b0a0ed4a4 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -96,6 +96,7 @@ describe('DatadogProvider', () => { "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, "nativeInteractionTracking": false, + "nativeIosResourceTracking": undefined, "nativeLongTaskThresholdMs": 200, "nativeViewTracking": false, "resourceEventMapper": null, @@ -178,6 +179,87 @@ describe('DatadogProvider', () => { }); }); + describe('nativeIosResourceTracking', () => { + it('sends nativeIosResourceTracking undefined to native by default', async () => { + renderWithProvider(); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + const receivedConfiguration = + NativeModules.DdSdk.initialize.mock.calls[0][0]; + expect(receivedConfiguration.rumConfiguration).toBeDefined(); + expect( + receivedConfiguration.rumConfiguration.nativeIosResourceTracking + ).toBeUndefined(); + }); + + it.each([true, false])( + 'sends nativeIosResourceTracking %s to native when set', + async value => { + const configuration = getDefaultConfiguration(); + const rumConfiguration = configuration.rumConfiguration; + if (rumConfiguration) { + rumConfiguration.nativeIosResourceTracking = value; + } + renderWithProvider({ configuration }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + const receivedConfiguration = + NativeModules.DdSdk.initialize.mock.calls[0][0]; + expect( + receivedConfiguration.rumConfiguration + .nativeIosResourceTracking + ).toBe(value); + } + ); + + it.each([ + [undefined, undefined, undefined], + [true, undefined, true], + [false, undefined, false], + [undefined, true, true], + [undefined, false, false] + ])( + 'partial initialization: features %s, initialize %s => native %s', + async (featuresValue, initializeValue, expected) => { + renderWithProvider({ + configuration: { + rumConfiguration: { + trackErrors: false, + trackResources: false, + trackInteractions: false, + ...(featuresValue !== undefined && { + nativeIosResourceTracking: featuresValue + }) + }, + traceConfiguration: {}, + logsConfiguration: {} + } + }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).not.toHaveBeenCalled(); + + await DatadogProvider.initialize({ + clientToken: 'fake-client-token', + env: 'fake-env', + rumConfiguration: { + applicationId: 'fake-application-id', + ...(initializeValue !== undefined && { + nativeIosResourceTracking: initializeValue + }) + } + }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + const receivedConfiguration = + NativeModules.DdSdk.initialize.mock.calls[0][0]; + expect( + receivedConfiguration.rumConfiguration + .nativeIosResourceTracking + ).toBe(expected); + } + ); + }); + describe('onInitialization callback', () => { it('runs after initialization when SYNC initialization', async () => { const onInitialization = jest.fn();