From 8eb296af1b6ef129c1ba812113ffeaa8011b5321 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Mon, 11 May 2026 10:32:08 +0200 Subject: [PATCH 01/16] [RUM-16084] Add remote configuration fetch and cache --- Datadog/Datadog.xcodeproj/project.pbxproj | 20 ++ DatadogCore/Sources/Core/DatadogCore.swift | 20 +- .../Core/RemoteConfigurationCache.swift | 54 +++++ .../Core/RemoteConfigurationFetcher.swift | 74 +++++++ DatadogCore/Sources/Datadog.swift | 8 + .../Sources/DatadogConfiguration.swift | 21 +- .../Core/RemoteConfigurationCacheTests.swift | 65 ++++++ .../RemoteConfigurationFetcherTests.swift | 187 ++++++++++++++++++ DatadogCore/Tests/Datadog/DatadogTests.swift | 76 +++++++ .../Sources/Context/DatadogSite.swift | 15 ++ .../Tests/Context/DatadogSiteTests.swift | 68 +++++++ 11 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 DatadogCore/Sources/Core/RemoteConfigurationCache.swift create mode 100644 DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift create mode 100644 DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift create mode 100644 DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift create mode 100644 DatadogInternal/Tests/Context/DatadogSiteTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index f29b9dfe55..976c8fbe50 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -18,6 +18,11 @@ 09A936A02F0EB989000B6379 /* SamplingMechanismType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369A2F0EB989000B6379 /* SamplingMechanismType.swift */; }; 09A936A12F0EB989000B6379 /* SamplingPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */; }; 09A936A22F0EB989000B6379 /* SpanContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369C2F0EB989000B6379 /* SpanContext.swift */; }; + 1019DFB52FA38A54006599B4 /* RemoteConfigurationCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */; }; + 1019DFB72FA38AC9006599B4 /* RemoteConfigurationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */; }; + 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */; }; + 1019DFBB2FA3910A006599B4 /* RemoteConfigurationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */; }; + 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */; }; 11030D5F2D959EAD00732D5F /* DatadogLogs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D207317C29A5226A00ECBF94 /* DatadogLogs.framework */; }; 11030D6D2D95A48B00732D5F /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; }; 11030D772D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */; }; @@ -1770,6 +1775,11 @@ 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplingPriority.swift; sourceTree = ""; }; 09A9369C2F0EB989000B6379 /* SpanContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpanContext.swift; sourceTree = ""; }; 0D2A20EFB1D82D0B8AC781F9 /* HeatmapMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMocks.swift; sourceTree = ""; }; + 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationCacheTests.swift; sourceTree = ""; }; + 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationCache.swift; sourceTree = ""; }; + 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcherTests.swift; sourceTree = ""; }; + 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcher.swift; sourceTree = ""; }; + 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogSiteTests.swift; sourceTree = ""; }; 11014EACF1FB9927DAD57822 /* EvaluationMocks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvaluationMocks.swift; sourceTree = ""; }; 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHitchesMetric.swift; sourceTree = ""; }; 110311752EF96ED000750DD4 /* DDLogs+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDLogs+apiTests.m"; sourceTree = ""; }; @@ -4456,6 +4466,8 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( + 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */, + 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -4582,6 +4594,8 @@ 61133C212423990D00786299 /* Core */ = { isa = PBXGroup; children = ( + 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */, + 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */, 61DA8CAB2861C3720074A606 /* DirectoriesTests.swift */, 61EF78C0257F842000EDCCB3 /* FeatureTests.swift */, 61345612244756E300E7DA6B /* PerformancePresetTests.swift */, @@ -6621,6 +6635,7 @@ D2DA239C298D58F300C6C7E6 /* Context */ = { isa = PBXGroup; children = ( + 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */, D2DA239D298D58F300C6C7E6 /* AppStateHistoryTests.swift */, D2DA239E298D58F300C6C7E6 /* DeviceInfoTests.swift */, 6174D6152BFDF29B00EC7469 /* BundleTypeTests.swift */, @@ -7881,12 +7896,14 @@ D2A7840F29A53B2F003B03BB /* Directory.swift in Sources */, 61D3E0DB277B23F1008BE766 /* KronosNSTimer+ClosureKit.swift in Sources */, D20605A92874C1CD0047275C /* NetworkConnectionInfoPublisher.swift in Sources */, + 1019DFB72FA38AC9006599B4 /* RemoteConfigurationCache.swift in Sources */, 614396722A67D74F00197326 /* BatchMetrics.swift in Sources */, D20605A6287476230047275C /* ServerOffsetPublisher.swift in Sources */, 613E792F2577B0F900DFCC17 /* Reader.swift in Sources */, A731B8042EE1DD0D003D1E4F /* CrossPlatformExtension.swift in Sources */, 61D3E0D3277B23F1008BE766 /* KronosDNSResolver.swift in Sources */, D2DC4BF627F484AA00E4FB96 /* DataEncryption.swift in Sources */, + 1019DFBB2FA3910A006599B4 /* RemoteConfigurationFetcher.swift in Sources */, 11EA5C2E2DC3CCA500E8DFA2 /* DatadogConfiguration.swift in Sources */, D2FB1254292E0E96005B13F8 /* TrackingConsentPublisher.swift in Sources */, 61D3E0D6277B23F1008BE766 /* KronosClock.swift in Sources */, @@ -7950,6 +7967,7 @@ 61133C5A2423990D00786299 /* FileTests.swift in Sources */, 61DB33B225DEDFC200F7EA71 /* CustomObjcViewController.m in Sources */, D2EFA875286E011900F1FAA6 /* DatadogContextProviderTests.swift in Sources */, + 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */, 61363D9F24D99BAA0084CD6F /* DDErrorTests.swift in Sources */, A727C4BB2BADB3AB00707DFD /* DDSessionReplay+apiTests.m in Sources */, 110311762EF96F6700750DD4 /* DDLogs+apiTests.m in Sources */, @@ -8014,6 +8032,7 @@ 1132B34D2D9E9A30002384F2 /* RUMViewHitchesMetricIntegrationTests.swift in Sources */, D2A1EE442886B8B400D28DFB /* UserInfoPublisherTests.swift in Sources */, D29A9FD029DDC58E005C54A4 /* RUMFeatureTests.swift in Sources */, + 1019DFB52FA38A54006599B4 /* RemoteConfigurationCacheTests.swift in Sources */, 61F930C82BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */, D28F836529C9E69E00EF8EA2 /* DatadogTraceFeatureTests.swift in Sources */, 61133C4B2423990D00786299 /* DDLogsTests.swift in Sources */, @@ -8988,6 +9007,7 @@ 116F84062CFDD06700705755 /* SampleRateTests.swift in Sources */, 3C0D5DF52A5443B100446CF9 /* DataFormatTests.swift in Sources */, D2EBEE4429BA168200B15732 /* TraceIDTests.swift in Sources */, + 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */, D2EBEE4329BA168200B15732 /* TraceIDGeneratorTests.swift in Sources */, D2DA23A7298D58F400C6C7E6 /* AppStateHistoryTests.swift in Sources */, D2EBEE3D29BA163E00B15732 /* W3CHTTPHeadersWriterTests.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index c7b8738e2b..2f4ea26d4f 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,6 +55,11 @@ internal final class DatadogCore { /// The message-bus instance. let bus = MessageBus() + /// Cache of the remote configuration JSON fetched from the CDN. + /// Created at init using this core's directory. `data` is `nil` until a + /// successful CDN fetch has been written and the app relaunched. + private let remoteConfigCache: RemoteConfigurationCache + /// Registry for Features. @ReadWriteLock private(set) var stores: [String: (storage: FeatureStorage, upload: FeatureUpload)] = [:] @@ -114,7 +119,7 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - + self.remoteConfigCache = RemoteConfigurationCache(directory: directory.coreDirectory) // connect the core to the message bus. // the bus will keep a weak ref to the core. bus.connect(core: self) @@ -255,6 +260,19 @@ internal final class DatadogCore { allDataStores.forEach { $0.clearAllData() } } + /// Fetches the remote configuration document from the CDN and caches it to disk. + /// + /// Called from `Datadog.initialize()` when `remoteConfigurationID` is set. + /// The fetch is fire-and-forget — SDK init does not wait for it. + internal func fetchRemoteConfiguration(from endpoint: URL, session: URLSession) { + let fetcher = RemoteConfigurationFetcher( + cache: remoteConfigCache, + telemetry: telemetry, + session: session + ) + fetcher.fetch(from: endpoint) + } + /// Adds a message receiver to the bus. /// /// After being added to the bus, the core will send the current context to receiver. diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift new file mode 100644 index 0000000000..72f82e9159 --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -0,0 +1,54 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Manages the on-disk cache of the remote configuration JSON document. +/// +/// The cache is a single file `remote-config.json` stored at the root of +/// the SDK's private core directory: +/// +/// /Library/Caches/com.datadoghq/v2//remote-config.json +/// +/// The file contains raw JSON bytes exactly as received from the CDN. +/// Parsing and applying those values is handled separately. +internal final class RemoteConfigurationCache { + private static let fileName = "remote-config.json" + + private let fileURL: URL + + /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. + /// `nil` when no cache exists yet (first launch, or remoteConfigurationID was never set). + private(set) var data: Data? + + init(directory: Directory) { + self.fileURL = directory.url.appendingPathComponent(Self.fileName) + // Synchronous read on the caller's thread (main thread during SDK init). + // Acceptable because the file is small (a single JSON document) and only + // present after a previous successful fetch — absent on first launch. + self.data = Self.readFromDisk(at: fileURL) + } + + // MARK: - Private + + private static func readFromDisk(at url: URL) -> Data? { + guard FileManager.default.fileExists(atPath: url.path) + else { + return nil + } + return try? Data(contentsOf: url) + } + + // MARK: - Internal + + /// Writes raw CDN response bytes to disk atomically. + /// Called only on a successful CDN response — never on failure. + /// Write errors are swallowed silently; the cache is best-effort. + func save(_ data: Data) { + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift new file mode 100644 index 0000000000..48854d9ada --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift @@ -0,0 +1,74 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Fetches the remote configuration JSON document from the CDN and delegates +/// storage to `RemoteConfigurationCache`. +/// +/// Rules: +/// - Fetch is always asynchronous — never blocks the caller. +/// - On success (2xx, non-empty body, valid JSON): calls `cache.save(_:)`. +/// - On any failure: reports a telemetry error and leaves the existing cache untouched. +internal final class RemoteConfigurationFetcher { + private let cache: RemoteConfigurationCache + private let telemetry: Telemetry + private let session: URLSession + + init( + cache: RemoteConfigurationCache, + telemetry: Telemetry, + session: URLSession = URLSession(configuration: .ephemeral) + ) { + self.cache = cache + self.telemetry = telemetry + self.session = session + } + + /// Fires a background GET request to `endpoint`. + /// + /// - Parameter endpoint: The CDN URL to fetch from. + /// - Parameter didComplete: Called when the fetch (and any write) is done. + /// Pass `nil` in production; inject a closure in tests to await completion. + func fetch(from endpoint: URL, didComplete: (() -> Void)? = nil) { + let cache = self.cache + let telemetry = self.telemetry + let task = session.dataTask(with: endpoint) { data, response, error in + defer { didComplete?() } + + // 1. Network error + if let error = error { + telemetry.error("[RemoteConfig] Network error", error: error) + return + } + + // 2. Non-2xx HTTP status + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + telemetry.error("[RemoteConfig] Non-2xx response: \(code)") + return + } + + // 3. Empty body + guard let data = data, !data.isEmpty else { + telemetry.error("[RemoteConfig] Empty response body") + return + } + + // 4. Invalid JSON + guard (try? JSONSerialization.jsonObject(with: data)) != nil else { + telemetry.error("[RemoteConfig] Response is not valid JSON") + return + } + + // All checks passed — persist to disk + cache.save(data) + } + task.resume() + } +} diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 1c054cd7c6..b493494390 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -297,6 +297,14 @@ public enum Datadog { CITestIntegration.active?.startIntegration() CoreRegistry.register(core, named: instanceName) + + // Trigger remote config fetch if an ID was provided. + // The fetch is async — init returns immediately and does not wait for it. + if let id = configuration.remoteConfigurationID, + let endpoint = configuration.site.remoteConfigurationURL(for: id) { + core.fetchRemoteConfiguration(from: endpoint, session: configuration.remoteConfigurationSession) + } + deleteV1Folders(in: core) DD.logger = InternalLogger( diff --git a/DatadogCore/Sources/DatadogConfiguration.swift b/DatadogCore/Sources/DatadogConfiguration.swift index 5455d8c573..0477f57315 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -123,6 +123,15 @@ extension Datadog { /// `false` by default. public var backgroundTasksEnabled: Bool + /// The remote configuration ID used to fetch SDK settings from the Datadog CDN. + /// + /// When non-nil, the SDK constructs a CDN URL from this ID and the resolved `site`, + /// fetches the remote config document asynchronously at startup, and caches the + /// raw JSON to disk for use on subsequent launches. + /// + /// Default is `nil` — no fetch is performed. + public var remoteConfigurationID: String? = nil + /// Creates a Datadog SDK Configuration object. /// /// - Parameters: @@ -169,6 +178,11 @@ extension Datadog { /// Tasks are normally stopped when there's nothing to upload or when encountering /// any upload blocker such us no internet connection or low battery. /// By default it's set to `false`. + /// + /// - remoteConfigurationID: The identifier used to fetch SDK settings from the Datadog CDN. + /// When non-nil, the SDK fetches the remote configuration document + /// asynchronously at startup and caches the raw JSON for subsequent launches. + /// Default is `nil` — no fetch is performed. public init( clientToken: String, env: String, @@ -182,7 +196,8 @@ extension Datadog { encryption: DataEncryption? = nil, serverDateProvider: ServerDateProvider? = nil, batchProcessingLevel: BatchProcessingLevel = .medium, - backgroundTasksEnabled: Bool = false + backgroundTasksEnabled: Bool = false, + remoteConfigurationID: String? = nil ) { self.clientToken = clientToken self.env = env @@ -197,6 +212,7 @@ extension Datadog { self.serverDateProvider = serverDateProvider ?? DatadogNTPDateProvider() self.batchProcessingLevel = batchProcessingLevel self.backgroundTasksEnabled = backgroundTasksEnabled + self.remoteConfigurationID = remoteConfigurationID } // MARK: - Internal @@ -228,5 +244,8 @@ extension Datadog { /// The default application state provider for accessing [application state](https://developer.apple.com/documentation/uikit/uiapplication/state). internal var appStateProvider: AppStateProvider = DefaultAppStateProvider() + + /// The URLSession used for remote configuration fetching. Replaceable in tests. + internal var remoteConfigurationSession: URLSession = URLSession(configuration: .ephemeral) } } diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift new file mode 100644 index 0000000000..729720f292 --- /dev/null +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift @@ -0,0 +1,65 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +@testable import DatadogCore + +class RemoteConfigurationCacheTests: XCTestCase { + private var coreDir = temporaryUniqueCoreDirectory() + + override func setUp() { + super.setUp() + coreDir = temporaryUniqueCoreDirectory() + coreDir.create() + } + + override func tearDown() { + coreDir.delete() + super.tearDown() + } + + // MARK: Read at init + + func testReturnsNilWhenNoCacheExists() { + let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + XCTAssertNil(cache.data) + } + + // MARK: Persistence across instances (simulates app relaunch) + + func testDataReadBackOnNextInit() { + let payload = Data("{\"session_sample_rate\":50}".utf8) + + // First "launch": save data + let cache1 = RemoteConfigurationCache(directory: coreDir.coreDirectory) + cache1.save(payload) + + // Second "launch": a fresh instance must read it back + let cache2 = RemoteConfigurationCache(directory: coreDir.coreDirectory) + XCTAssertEqual(cache2.data, payload) + } + + func testSaveOverwritesPreviousFile() { + let first = Data("{\"v\":1}".utf8) + let second = Data("{\"v\":2}".utf8) + + RemoteConfigurationCache(directory: coreDir.coreDirectory).save(first) + RemoteConfigurationCache(directory: coreDir.coreDirectory).save(second) + + let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + XCTAssertEqual(cache.data, second) + } + + // MARK: Failure resilience + + func testSaveFailsSilentlyWhenDirectoryMissing() { + let missing = Directory(url: URL(fileURLWithPath: "/no/such/path/")) + let cache = RemoteConfigurationCache(directory: missing) + // Must not crash + cache.save(Data("{\"k\":\"v\"}".utf8)) + } +} diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift new file mode 100644 index 0000000000..d6afcf8ec0 --- /dev/null +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -0,0 +1,187 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +import DatadogInternal +@testable import DatadogCore + +// MARK: MockURLProtocol + +private class MockURLProtocol: URLProtocol { + /// Set this before each test to control what the mock returns. + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { client?.urlProtocol(self, didLoad: data) } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +// MARK: Helper + +private func mockSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) +} + +private func okResponse(for url: URL) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! +} + +private func errorResponse(for url: URL, status: Int) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)! +} + +// MARK: Tests + +class RemoteConfigurationFetcherTests: XCTestCase { + private var coreDir: CoreDirectory! // swiftlint:disable:this implicitly_unwrapped_optional + private var cache: RemoteConfigurationCache! // swiftlint:disable:this implicitly_unwrapped_optional + private let endpoint = URL(string: "https://example.com/remote-config")! + + override func setUp() { + coreDir = temporaryUniqueCoreDirectory() + coreDir.create() + cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + coreDir.delete() + } + + // MARK: Success path + + func testSuccessfulFetchWritesToCache() { + let payload = Data("{\"session_sample_rate\":50}".utf8) + + let expectedEndpoint = endpoint + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.url, expectedEndpoint) + return (okResponse(for: request.url!), payload) + } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) + + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + let freshCache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + XCTAssertEqual(freshCache.data, payload, "Cache must contain the CDN response") + XCTAssertFalse( + telemetry.messages.contains { if case .error = $0 { return true }; return false }, + "No telemetry errors expected on success" + ) + } + + // MARK: Failure paths cache must not be overwritten + + func testNetworkErrorDoesNotOverwriteExistingCache() { + let existing = Data("{\"v\":1}".utf8) + cache.save(existing) + + MockURLProtocol.requestHandler = { _ in throw URLError(.networkConnectionLost) } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + XCTAssertEqual( + RemoteConfigurationCache(directory: coreDir.coreDirectory).data, + existing, + "Existing cache must be preserved after a network error" + ) + XCTAssertTrue( + telemetry.messages.contains { if case .error = $0 { return true }; return false }, + "A telemetry error must be reported" + ) + } + + func testNon2xxResponseDoesNotOverwriteExistingCache() { + let existing = Data("{\"v\":1}".utf8) + cache.save(existing) + + MockURLProtocol.requestHandler = { request in + (errorResponse(for: request.url!, status: 500), nil) + } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) + XCTAssertTrue( + telemetry.messages.contains { if case .error = $0 { return true }; return false }, + "A telemetry error must be reported" + ) + } + + func testEmptyBodyDoesNotOverwriteExistingCache() { + let existing = Data("{\"v\":1}".utf8) + cache.save(existing) + + MockURLProtocol.requestHandler = { request in + (okResponse(for: request.url!), Data()) // empty body + } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) + XCTAssertTrue( + telemetry.messages.contains { if case .error = $0 { return true }; return false }, + "A telemetry error must be reported" + ) + } + + func testInvalidJSONDoesNotOverwriteExistingCache() { + let existing = Data("{\"v\":1}".utf8) + cache.save(existing) + + MockURLProtocol.requestHandler = { request in + (okResponse(for: request.url!), Data("this is not json".utf8)) + } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) + XCTAssertTrue( + telemetry.messages.contains { if case .error = $0 { return true }; return false }, + "A telemetry error must be reported" + ) + } +} diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index 21fb7d3fea..f863d9ad82 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -12,6 +12,34 @@ import TestUtilities @testable import DatadogTrace @testable import DatadogCore +// MARK: RemoteConfigMockURLProtocol + +private class RemoteConfigMockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = RemoteConfigMockURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { client?.urlProtocol(self, didLoad: data) } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +// MARK: DatadogTests + class DatadogTests: XCTestCase { private var printFunction: PrintFunctionSpy! // swiftlint:disable:this implicitly_unwrapped_optional private var defaultConfig = Datadog.Configuration(clientToken: "abc-123", env: "tests") @@ -27,6 +55,7 @@ class DatadogTests: XCTestCase { override func tearDown() { consolePrint = { message, _ in print(message) } printFunction = nil + RemoteConfigMockURLProtocol.requestHandler = nil XCTAssertFalse(Datadog.isInitialized()) super.tearDown() } @@ -521,6 +550,53 @@ class DatadogTests: XCTestCase { XCTAssertThrowsError(try cache.subdirectory(path: "com.datadoghq.rum")) } + // MARK: Remote Configuration + + func testGivenNoRemoteConfigurationID_fetchIsSkipped() { + // Given — inject a session whose handler fails the test if ever called + RemoteConfigMockURLProtocol.requestHandler = { _ in + XCTFail("No remote config fetch should occur when remoteConfigurationID is nil") + throw URLError(.cancelled) + } + var config = defaultConfig + // remoteConfigurationID is nil by default + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] + config.remoteConfigurationSession = URLSession(configuration: sessionConfig) + + // When + Datadog.initialize(with: config, trackingConsent: .granted) + + // Then — reaching here without XCTFail confirms no fetch was triggered. + // No expectation wait is needed: when remoteConfigurationID is nil the guard + // in Datadog.swift returns early and no URLSession task is ever scheduled. + Datadog.flushAndDeinitialize() + } + + func testGivenRemoteConfigurationID_fetchIsTriggered() { + // Given + let fetchExpectation = expectation(description: "remote config fetch triggered") + RemoteConfigMockURLProtocol.requestHandler = { request in + fetchExpectation.fulfill() + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, + Data("{}".utf8) + ) + } + var config = defaultConfig + config.remoteConfigurationID = "test-id" + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] + config.remoteConfigurationSession = URLSession(configuration: sessionConfig) + + // When + Datadog.initialize(with: config, trackingConsent: .granted) + defer { Datadog.flushAndDeinitialize() } + + // Then + waitForExpectations(timeout: 5) + } + func testCustomSDKInstance() throws { // When Datadog.initialize( diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 22b605a836..8f2eea4cab 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -48,4 +48,19 @@ extension DatadogSite { // swiftlint:enable force_unwrapping } } + + /// Constructs the CDN URL for fetching the remote configuration document. + /// - Parameter id: The value of `Datadog.Configuration.remoteConfigurationID`. + /// - Returns: URL to GET the config JSON from, or `nil` if `id` cannot be percent-encoded. + public func remoteConfigurationURL(for id: String) -> URL? { + // Format: https://sdk-configuration.browser-intake-{site}/v1/{id}.json + // `.urlPathAllowed` leaves `/` unencoded (it is legal in a path). + // Subtract it so a slash in the ID doesn't produce extra path segments. + let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + guard let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { + return nil + } + // swiftlint:disable:next force_unwrapping + return URL(string: "https://sdk-configuration.\(endpoint.host!)/v1/\(encoded).json") + } } diff --git a/DatadogInternal/Tests/Context/DatadogSiteTests.swift b/DatadogInternal/Tests/Context/DatadogSiteTests.swift new file mode 100644 index 0000000000..067d689f1f --- /dev/null +++ b/DatadogInternal/Tests/Context/DatadogSiteTests.swift @@ -0,0 +1,68 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import DatadogInternal + +class DatadogSiteTests: XCTestCase { + // MARK: - remoteConfigurationURL per site + + func testUS1RemoteConfigurationURL() { + let url = DatadogSite.us1.remoteConfigurationURL(for: "abc-123") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/abc-123.json") + } + + func testUS3RemoteConfigurationURL() { + let expected = "https://sdk-configuration.browser-intake-us3-datadoghq.com/v1/abc-123.json" + XCTAssertEqual(DatadogSite.us3.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + } + + func testUS5RemoteConfigurationURL() { + let expected = "https://sdk-configuration.browser-intake-us5-datadoghq.com/v1/abc-123.json" + XCTAssertEqual(DatadogSite.us5.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + } + + func testEU1RemoteConfigurationURL() { + let url = DatadogSite.eu1.remoteConfigurationURL(for: "abc-123") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.eu/v1/abc-123.json") + } + + func testAP1RemoteConfigurationURL() { + let expected = "https://sdk-configuration.browser-intake-ap1-datadoghq.com/v1/abc-123.json" + XCTAssertEqual(DatadogSite.ap1.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + } + + func testAP2RemoteConfigurationURL() { + let expected = "https://sdk-configuration.browser-intake-ap2-datadoghq.com/v1/abc-123.json" + XCTAssertEqual(DatadogSite.ap2.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + } + + func testUS1FedRemoteConfigurationURL() { + let url = DatadogSite.us1_fed.remoteConfigurationURL(for: "abc-123") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-ddog-gov.com/v1/abc-123.json") + } + + func testUS2FedRemoteConfigurationURL() { + let expected = "https://sdk-configuration.browser-intake-us2-ddog-gov.com/v1/abc-123.json" + XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + } + + // MARK: - ID encoding + + func testIDWithSpacesIsPercentEncoded() { + let url = DatadogSite.us1.remoteConfigurationURL(for: "hello world") + let expected = "https://sdk-configuration.browser-intake-datadoghq.com/v1/hello%20world.json" + XCTAssertNotNil(url, "URL must be constructed even when id contains spaces") + XCTAssertEqual(url?.absoluteString, expected) + } + + func testIDWithSlashDoesNotProduceExtraPathSegments() { + // A slash in the ID must be encoded as %2F, not left as a literal path separator. + // Without this, "a/b" would produce …/v1/a/b.json (wrong path) instead of …/v1/a%2Fb.json. + let url = DatadogSite.us1.remoteConfigurationURL(for: "a/b") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/a%2Fb.json") + } +} From a0e3c936a772f9015e569afeeefec8480838343c Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 09:41:55 +0200 Subject: [PATCH 02/16] [RUM-16084] Improve telemetry and add comments --- .../Core/RemoteConfigurationCache.swift | 1 + .../Core/RemoteConfigurationFetcher.swift | 12 ++++++- DatadogCore/Sources/Datadog.swift | 9 +++-- .../RemoteConfigurationFetcherTests.swift | 35 ++++++++++++++++--- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index 72f82e9159..de1b267ff7 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -23,6 +23,7 @@ internal final class RemoteConfigurationCache { /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. /// `nil` when no cache exists yet (first launch, or remoteConfigurationID was never set). + /// Consumed by the config-application layer in a follow-on ticket (out of scope for RUM-16084). private(set) var data: Data? init(directory: Directory) { diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift index 48854d9ada..9bee02ead3 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift @@ -50,7 +50,13 @@ internal final class RemoteConfigurationFetcher { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - telemetry.error("[RemoteConfig] Non-2xx response: \(code)") + // Use a fixed message so all HTTP errors bucket together in telemetry; + // the status code lives in the error object, not the message string. + telemetry.error("[RemoteConfig] Non-2xx response", error: NSError( + domain: "RemoteConfiguration", + code: code, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(code)"] + )) return } @@ -61,6 +67,10 @@ internal final class RemoteConfigurationFetcher { } // 4. Invalid JSON + // Intentional allocate-and-discard: we only need to validate the bytes + // are well-formed JSON before caching. The parsed object is thrown away. + // This guarantees the cache never contains non-JSON data, so future + // parsing layers can trust the cached bytes without re-validating. guard (try? JSONSerialization.jsonObject(with: data)) != nil else { telemetry.error("[RemoteConfig] Response is not valid JSON") return diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index b493494390..063c63f82c 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -300,9 +300,12 @@ public enum Datadog { // Trigger remote config fetch if an ID was provided. // The fetch is async — init returns immediately and does not wait for it. - if let id = configuration.remoteConfigurationID, - let endpoint = configuration.site.remoteConfigurationURL(for: id) { - core.fetchRemoteConfiguration(from: endpoint, session: configuration.remoteConfigurationSession) + if let id = configuration.remoteConfigurationID { + if let endpoint = configuration.site.remoteConfigurationURL(for: id) { + core.fetchRemoteConfiguration(from: endpoint, session: configuration.remoteConfigurationSession) + } else { + core.telemetry.error("[RemoteConfig] Could not build CDN URL for remoteConfigurationID '\(id)'") + } } deleteV1Folders(in: core) diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift index d6afcf8ec0..71a1b601d1 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -92,7 +92,12 @@ class RemoteConfigurationFetcherTests: XCTestCase { let freshCache = RemoteConfigurationCache(directory: coreDir.coreDirectory) XCTAssertEqual(freshCache.data, payload, "Cache must contain the CDN response") XCTAssertFalse( - telemetry.messages.contains { if case .error = $0 { return true }; return false }, + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, "No telemetry errors expected on success" ) } @@ -117,7 +122,12 @@ class RemoteConfigurationFetcherTests: XCTestCase { "Existing cache must be preserved after a network error" ) XCTAssertTrue( - telemetry.messages.contains { if case .error = $0 { return true }; return false }, + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, "A telemetry error must be reported" ) } @@ -138,7 +148,12 @@ class RemoteConfigurationFetcherTests: XCTestCase { XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) XCTAssertTrue( - telemetry.messages.contains { if case .error = $0 { return true }; return false }, + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, "A telemetry error must be reported" ) } @@ -159,7 +174,12 @@ class RemoteConfigurationFetcherTests: XCTestCase { XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) XCTAssertTrue( - telemetry.messages.contains { if case .error = $0 { return true }; return false }, + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, "A telemetry error must be reported" ) } @@ -180,7 +200,12 @@ class RemoteConfigurationFetcherTests: XCTestCase { XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) XCTAssertTrue( - telemetry.messages.contains { if case .error = $0 { return true }; return false }, + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, "A telemetry error must be reported" ) } From c5d075a1b05af0f2e62bc809764d349c5f250461 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 10:07:39 +0200 Subject: [PATCH 03/16] [RUM-16084] Fix lint, update CHANGELOG --- CHANGELOG.md | 2 ++ DatadogCore/Sources/DatadogConfiguration.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c96600fd..d3193b96eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased - [IMPROVEMENT] Add support for `maui` source for cross-platform RUM events from .NET MAUI applications. See [#2891][] +- [FEATURE] Add `remoteConfigurationID` to `Datadog.Configuration` to fetch and cache the remote configuration document from the Datadog CDN at SDK startup. See [#2918][] - [FEATURE] Add client state management to `DatadogFlags` module. See [#2719][] - [IMPROVEMENT] Skip malformed RUM attributes individually instead of dropping the entire event, and log clear error messages. See [#2844][] - [FIX] Propagate native `anonymous_id` to WebView RUM and Log events. See [#2847][] @@ -1143,6 +1144,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2856]: https://github.com/DataDog/dd-sdk-ios/pull/2856 [#2866]: https://github.com/DataDog/dd-sdk-ios/pull/2866 [#2891]: https://github.com/DataDog/dd-sdk-ios/pull/2891 +[#2918]: https://github.com/DataDog/dd-sdk-ios/pull/2918 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin diff --git a/DatadogCore/Sources/DatadogConfiguration.swift b/DatadogCore/Sources/DatadogConfiguration.swift index 0477f57315..c6e64ad097 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -246,6 +246,6 @@ extension Datadog { internal var appStateProvider: AppStateProvider = DefaultAppStateProvider() /// The URLSession used for remote configuration fetching. Replaceable in tests. - internal var remoteConfigurationSession: URLSession = URLSession(configuration: .ephemeral) + internal var remoteConfigurationSession = URLSession(configuration: .ephemeral) } } From 0661d12cff6977cf1e68491482b13c613c50adff Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 10:47:49 +0200 Subject: [PATCH 04/16] [RUM-16084] ObjC bridge, SPI visibility, cache consistency --- DatadogCore/Sources/Core/RemoteConfigurationCache.swift | 3 ++- DatadogCore/Sources/Datadog.swift | 1 + DatadogCore/Sources/DatadogConfiguration+objc.swift | 9 +++++++++ DatadogCore/Sources/DatadogConfiguration.swift | 2 ++ .../Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m | 2 ++ DatadogInternal/Sources/Context/DatadogSite.swift | 1 + api-surface-objc | 1 + api-surface-swift | 7 +++++-- 8 files changed, 23 insertions(+), 3 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index de1b267ff7..0abd2b9802 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -46,10 +46,11 @@ internal final class RemoteConfigurationCache { // MARK: - Internal - /// Writes raw CDN response bytes to disk atomically. + /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. /// Called only on a successful CDN response — never on failure. /// Write errors are swallowed silently; the cache is best-effort. func save(_ data: Data) { try? data.write(to: fileURL, options: .atomic) + self.data = data } } diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 063c63f82c..9498c35281 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -5,6 +5,7 @@ */ import Foundation +@_spi(Internal) import DatadogInternal //swiftlint:disable duplicate_imports diff --git a/DatadogCore/Sources/DatadogConfiguration+objc.swift b/DatadogCore/Sources/DatadogConfiguration+objc.swift index 808d4d551e..bedf1ee219 100644 --- a/DatadogCore/Sources/DatadogConfiguration+objc.swift +++ b/DatadogCore/Sources/DatadogConfiguration+objc.swift @@ -273,6 +273,15 @@ public final class objc_Configuration: NSObject { set { sdkConfiguration.backgroundTasksEnabled = newValue } } + /// The remote configuration ID used to fetch SDK settings from the Datadog CDN at startup. + /// + /// When non-nil, the SDK asynchronously fetches and caches the remote configuration document. + /// Default is `nil` — no fetch is performed. + public var remoteConfigurationID: String? { + get { sdkConfiguration.remoteConfigurationID } + set { sdkConfiguration.remoteConfigurationID = newValue } + } + /// Creates a Datadog SDK Configuration object. /// /// - Parameters: diff --git a/DatadogCore/Sources/DatadogConfiguration.swift b/DatadogCore/Sources/DatadogConfiguration.swift index c6e64ad097..bc60533dc6 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -246,6 +246,8 @@ extension Datadog { internal var appStateProvider: AppStateProvider = DefaultAppStateProvider() /// The URLSession used for remote configuration fetching. Replaceable in tests. + /// TODO: Build this session from `proxyConfiguration` so remote config fetches respect + /// proxy settings in restricted network environments (same as the main HTTP client). internal var remoteConfigurationSession = URLSession(configuration: .ephemeral) } } diff --git a/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m b/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m index 8dcc152f48..9c5110619b 100644 --- a/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m +++ b/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m @@ -67,6 +67,8 @@ - (void)testDDConfigurationBuilderAPI { configuration.additionalConfiguration = @{@"additional": @"config"}; [configuration setEncryption:[CustomDDDataEncryption new]]; configuration.backgroundTasksEnabled = true; + configuration.remoteConfigurationID = @"abc-123"; + configuration.remoteConfigurationID = nil; } - (void)testDatadogCrashReporterAPI { diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 8f2eea4cab..1f0a0121fb 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -52,6 +52,7 @@ extension DatadogSite { /// Constructs the CDN URL for fetching the remote configuration document. /// - Parameter id: The value of `Datadog.Configuration.remoteConfigurationID`. /// - Returns: URL to GET the config JSON from, or `nil` if `id` cannot be percent-encoded. + @_spi(Internal) public func remoteConfigurationURL(for id: String) -> URL? { // Format: https://sdk-configuration.browser-intake-{site}/v1/{id}.json // `.urlPathAllowed` leaves `/` unencoded (it is legal in a path). diff --git a/api-surface-objc b/api-surface-objc index fe8287474b..9d6a205372 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -61,6 +61,7 @@ public final class objc_Configuration: NSObject public var bundle: Bundle public var additionalConfiguration: [String: Any] public var backgroundTasksEnabled: Bool + public var remoteConfigurationID: String? public init(clientToken: String, env: String) public final class objc_URLSessionInstrumentationConfiguration: NSObject public init(delegateClass: URLSessionDataDelegate.Type) diff --git a/api-surface-swift b/api-surface-swift index e8fd72f687..035d8b82c6 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -59,7 +59,8 @@ public extension Datadog public var bundle: Bundle public var batchProcessingLevel: BatchProcessingLevel public var backgroundTasksEnabled: Bool - public init(clientToken: String,env: String,site: DatadogSite = .us1,service: String? = nil,version: String? = nil,bundle: Bundle = .main,batchSize: BatchSize = .medium,uploadFrequency: UploadFrequency = .average,proxyConfiguration: [AnyHashable: Any]? = nil,encryption: DataEncryption? = nil,serverDateProvider: ServerDateProvider? = nil,batchProcessingLevel: BatchProcessingLevel = .medium,backgroundTasksEnabled: Bool = false) + public var remoteConfigurationID: String? = nil + public init(clientToken: String,env: String,site: DatadogSite = .us1,service: String? = nil,version: String? = nil,bundle: Bundle = .main,batchSize: BatchSize = .medium,uploadFrequency: UploadFrequency = .average,proxyConfiguration: [AnyHashable: Any]? = nil,encryption: DataEncryption? = nil,serverDateProvider: ServerDateProvider? = nil,batchProcessingLevel: BatchProcessingLevel = .medium,backgroundTasksEnabled: Bool = false,remoteConfigurationID: String? = nil) public class SharedContext: NSObject public let userId: String? public let accountId: String? @@ -1317,6 +1318,8 @@ public protocol FlagsClientProtocol: AnyObject var state: FlagsStateObservable func setEvaluationContext(_ context: FlagsEvaluationContext,completion: @escaping (Result) -> Void) func getDetails(key: String, defaultValue: T) -> FlagDetails where T: Equatable, T: FlagValue +[?] extension FlagsClientProtocol + public var state: FlagsStateObservable [?] extension FlagsClientProtocol public func setEvaluationContext(_ context: FlagsEvaluationContext) public func setEvaluationContext(_ context: FlagsEvaluationContext) async throws @@ -1333,7 +1336,7 @@ public protocol FlagsClientProtocol: AnyObject public func getIntegerDetails(key: String, defaultValue: Int) -> FlagDetails public func getDoubleDetails(key: String, defaultValue: Double) -> FlagDetails public func getObjectDetails(key: String, defaultValue: AnyValue) -> FlagDetails -public enum FlagsClientState: Equatable +public enum FlagsClientState: Sendable case notReady case ready case reconciling From ae5cd4bebc303386545b266f7896d87963f0a058 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 11:00:15 +0200 Subject: [PATCH 05/16] [RUM-16084] Fix URL encoding, cache consistency, and test determinism --- .../Sources/Core/RemoteConfigurationCache.swift | 12 ++++++++++-- DatadogCore/Tests/Datadog/DatadogTests.swift | 13 +++++++------ DatadogInternal/Sources/Context/DatadogSite.swift | 7 ++++--- .../Tests/Context/DatadogSiteTests.swift | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index 0abd2b9802..976d17685c 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -49,8 +49,16 @@ internal final class RemoteConfigurationCache { /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. /// Called only on a successful CDN response — never on failure. /// Write errors are swallowed silently; the cache is best-effort. + /// In-memory `data` is only updated when the disk write succeeds, keeping + /// the two in sync. func save(_ data: Data) { - try? data.write(to: fileURL, options: .atomic) - self.data = data + do { + try data.write(to: fileURL, options: .atomic) + self.data = data + } catch { + // Best-effort: disk write failures are silently ignored. + // self.data is intentionally NOT updated so in-memory state stays + // consistent with what is actually on disk. + } } } diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index f863d9ad82..161575ca78 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -553,9 +553,11 @@ class DatadogTests: XCTestCase { // MARK: Remote Configuration func testGivenNoRemoteConfigurationID_fetchIsSkipped() { - // Given — inject a session whose handler fails the test if ever called + // Given — inject a session that fulfils an inverted expectation if called + let noFetchExpectation = expectation(description: "no remote config fetch should occur") + noFetchExpectation.isInverted = true RemoteConfigMockURLProtocol.requestHandler = { _ in - XCTFail("No remote config fetch should occur when remoteConfigurationID is nil") + noFetchExpectation.fulfill() throw URLError(.cancelled) } var config = defaultConfig @@ -566,11 +568,10 @@ class DatadogTests: XCTestCase { // When Datadog.initialize(with: config, trackingConsent: .granted) + defer { Datadog.flushAndDeinitialize() } - // Then — reaching here without XCTFail confirms no fetch was triggered. - // No expectation wait is needed: when remoteConfigurationID is nil the guard - // in Datadog.swift returns early and no URLSession task is ever scheduled. - Datadog.flushAndDeinitialize() + // Then — the inverted expectation times out (i.e. passes) if no request is fired. + waitForExpectations(timeout: 0.5) } func testGivenRemoteConfigurationID_fetchIsTriggered() { diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 1f0a0121fb..7e890661e9 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -55,9 +55,10 @@ extension DatadogSite { @_spi(Internal) public func remoteConfigurationURL(for id: String) -> URL? { // Format: https://sdk-configuration.browser-intake-{site}/v1/{id}.json - // `.urlPathAllowed` leaves `/` unencoded (it is legal in a path). - // Subtract it so a slash in the ID doesn't produce extra path segments. - let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + // `.urlPathAllowed` leaves `/`, `?`, and `#` unencoded (they are legal in a URL). + // Subtract them so an ID containing those characters doesn't produce extra path + // segments, a query string, or a fragment. + let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#")) guard let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { return nil } diff --git a/DatadogInternal/Tests/Context/DatadogSiteTests.swift b/DatadogInternal/Tests/Context/DatadogSiteTests.swift index 067d689f1f..21e071951d 100644 --- a/DatadogInternal/Tests/Context/DatadogSiteTests.swift +++ b/DatadogInternal/Tests/Context/DatadogSiteTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) import DatadogInternal class DatadogSiteTests: XCTestCase { @@ -65,4 +66,18 @@ class DatadogSiteTests: XCTestCase { let url = DatadogSite.us1.remoteConfigurationURL(for: "a/b") XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/a%2Fb.json") } + + func testIDWithQuestionMarkDoesNotProduceQueryString() { + // A `?` in the ID must be encoded as %3F so it doesn't start a query string. + // Without this, "id?query" would produce …/v1/id?query.json (malformed URL). + let url = DatadogSite.us1.remoteConfigurationURL(for: "id?query") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%3Fquery.json") + } + + func testIDWithHashDoesNotProduceFragment() { + // A `#` in the ID must be encoded as %23 so it doesn't start a URL fragment. + // Without this, "id#section" would produce …/v1/id#section.json (truncated path). + let url = DatadogSite.us1.remoteConfigurationURL(for: "id#section") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%23section.json") + } } From fe567dc579c099a9d877b7f5753c33da92e2878f Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 11:38:48 +0200 Subject: [PATCH 06/16] [RUM-16084] Fix CHANGELOG PR reference to #2919 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3193b96eb..db7395eb8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Unreleased - [IMPROVEMENT] Add support for `maui` source for cross-platform RUM events from .NET MAUI applications. See [#2891][] -- [FEATURE] Add `remoteConfigurationID` to `Datadog.Configuration` to fetch and cache the remote configuration document from the Datadog CDN at SDK startup. See [#2918][] +- [FEATURE] Add `remoteConfigurationID` to `Datadog.Configuration` to fetch and cache the remote configuration document from the Datadog CDN at SDK startup. See [#2919][] - [FEATURE] Add client state management to `DatadogFlags` module. See [#2719][] - [IMPROVEMENT] Skip malformed RUM attributes individually instead of dropping the entire event, and log clear error messages. See [#2844][] - [FIX] Propagate native `anonymous_id` to WebView RUM and Log events. See [#2847][] @@ -1144,7 +1144,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2856]: https://github.com/DataDog/dd-sdk-ios/pull/2856 [#2866]: https://github.com/DataDog/dd-sdk-ios/pull/2866 [#2891]: https://github.com/DataDog/dd-sdk-ios/pull/2891 -[#2918]: https://github.com/DataDog/dd-sdk-ios/pull/2918 +[#2919]: https://github.com/DataDog/dd-sdk-ios/pull/2919 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin From dcab0d40d38f61247d6ac6deb1ed7d29b4460bdf Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 11:57:27 +0200 Subject: [PATCH 07/16] [RUM-16084] Build proxy-aware URLSession for remote config fetch --- DatadogCore/Sources/Datadog.swift | 8 +++++++- DatadogCore/Sources/DatadogConfiguration.swift | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 9498c35281..32c28882cc 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -303,7 +303,13 @@ public enum Datadog { // The fetch is async — init returns immediately and does not wait for it. if let id = configuration.remoteConfigurationID { if let endpoint = configuration.site.remoteConfigurationURL(for: id) { - core.fetchRemoteConfiguration(from: endpoint, session: configuration.remoteConfigurationSession) + let session = configuration.remoteConfigurationSession ?? { + let sessionConfig: URLSessionConfiguration = .ephemeral + sessionConfig.urlCache = nil + sessionConfig.connectionProxyDictionary = configuration.proxyConfiguration + return URLSession(configuration: sessionConfig) + }() + core.fetchRemoteConfiguration(from: endpoint, session: session) } else { core.telemetry.error("[RemoteConfig] Could not build CDN URL for remoteConfigurationID '\(id)'") } diff --git a/DatadogCore/Sources/DatadogConfiguration.swift b/DatadogCore/Sources/DatadogConfiguration.swift index bc60533dc6..abbfda9d31 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -245,9 +245,9 @@ extension Datadog { /// The default application state provider for accessing [application state](https://developer.apple.com/documentation/uikit/uiapplication/state). internal var appStateProvider: AppStateProvider = DefaultAppStateProvider() - /// The URLSession used for remote configuration fetching. Replaceable in tests. - /// TODO: Build this session from `proxyConfiguration` so remote config fetches respect - /// proxy settings in restricted network environments (same as the main HTTP client). - internal var remoteConfigurationSession = URLSession(configuration: .ephemeral) + /// Override the URLSession used for remote configuration fetching. For tests only. + /// When `nil` (the default), `Datadog.initialize()` builds a session from + /// `proxyConfiguration` — matching the proxy behaviour of the main HTTP client. + internal var remoteConfigurationSession: URLSession? = nil } } From d62ca814ed93067c9f8b87f324687958aecd9389 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 13 May 2026 16:24:39 +0200 Subject: [PATCH 08/16] [RUM-16084] Empty ID guard, comment fix, save failure telemetry --- DatadogCore/Sources/Core/DatadogCore.swift | 6 +++-- .../Core/RemoteConfigurationCache.swift | 8 +++--- .../Core/RemoteConfigurationFetcher.swift | 4 ++- DatadogCore/Sources/Datadog.swift | 2 +- .../Core/RemoteConfigurationCacheTests.swift | 10 ++++--- .../RemoteConfigurationFetcherTests.swift | 26 +++++++++++++++++++ 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 2f4ea26d4f..9d8b863039 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -56,8 +56,10 @@ internal final class DatadogCore { let bus = MessageBus() /// Cache of the remote configuration JSON fetched from the CDN. - /// Created at init using this core's directory. `data` is `nil` until a - /// successful CDN fetch has been written and the app relaunched. + /// Created at init using this core's directory. `data` is `nil` on first + /// launch (or when `remoteConfigurationID` was never set); populated either + /// from disk (previous successful fetch) or in-memory after the first + /// successful fetch in the current session. private let remoteConfigCache: RemoteConfigurationCache /// Registry for Features. diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index 976d17685c..f90f615e43 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -48,17 +48,19 @@ internal final class RemoteConfigurationCache { /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. /// Called only on a successful CDN response — never on failure. - /// Write errors are swallowed silently; the cache is best-effort. /// In-memory `data` is only updated when the disk write succeeds, keeping /// the two in sync. - func save(_ data: Data) { + /// - Returns: `true` if the write succeeded, `false` otherwise. + @discardableResult + func save(_ data: Data) -> Bool { do { try data.write(to: fileURL, options: .atomic) self.data = data + return true } catch { - // Best-effort: disk write failures are silently ignored. // self.data is intentionally NOT updated so in-memory state stays // consistent with what is actually on disk. + return false } } } diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift index 9bee02ead3..2021e6fef9 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift @@ -77,7 +77,9 @@ internal final class RemoteConfigurationFetcher { } // All checks passed — persist to disk - cache.save(data) + if !cache.save(data) { + telemetry.error("[RemoteConfig] Failed to write remote configuration to disk") + } } task.resume() } diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 32c28882cc..0c701570a8 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -301,7 +301,7 @@ public enum Datadog { // Trigger remote config fetch if an ID was provided. // The fetch is async — init returns immediately and does not wait for it. - if let id = configuration.remoteConfigurationID { + if let id = configuration.remoteConfigurationID, !id.isEmpty { if let endpoint = configuration.site.remoteConfigurationURL(for: id) { let session = configuration.remoteConfigurationSession ?? { let sessionConfig: URLSessionConfiguration = .ephemeral diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift index 729720f292..60b7993016 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift @@ -56,10 +56,14 @@ class RemoteConfigurationCacheTests: XCTestCase { // MARK: Failure resilience - func testSaveFailsSilentlyWhenDirectoryMissing() { + func testSaveReturnsTrueOnSuccess() { + let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + XCTAssertTrue(cache.save(Data("{\"k\":\"v\"}".utf8))) + } + + func testSaveReturnsFalseWhenDirectoryMissing() { let missing = Directory(url: URL(fileURLWithPath: "/no/such/path/")) let cache = RemoteConfigurationCache(directory: missing) - // Must not crash - cache.save(Data("{\"k\":\"v\"}".utf8)) + XCTAssertFalse(cache.save(Data("{\"k\":\"v\"}".utf8))) } } diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift index 71a1b601d1..7f4f99ccb5 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -209,4 +209,30 @@ class RemoteConfigurationFetcherTests: XCTestCase { "A telemetry error must be reported" ) } + + func testDiskWriteFailureReportsTelemetry() { + // Use a cache pointing at a non-existent directory so the write will fail + let missingDir = Directory(url: URL(fileURLWithPath: "/no/such/path/")) + let brokenCache = RemoteConfigurationCache(directory: missingDir) + + MockURLProtocol.requestHandler = { request in + (okResponse(for: request.url!), Data("{}".utf8)) + } + + let telemetry = TelemetryMock() + let expectation = expectation(description: "fetch completes") + let fetcher = RemoteConfigurationFetcher(cache: brokenCache, telemetry: telemetry, session: mockSession()) + fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) + wait(for: [expectation], timeout: 2) + + XCTAssertTrue( + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, + "A telemetry error must be reported when the disk write fails" + ) + } } From e51bd7d067fec29d382ae72174226080eefaf9b1 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Thu, 14 May 2026 09:17:12 +0200 Subject: [PATCH 09/16] [RUM-16084] Trim whitespace/newlines from remoteConfigurationID, fix comments --- .../Core/RemoteConfigurationCache.swift | 2 +- DatadogCore/Sources/Datadog.swift | 7 ++++-- DatadogCore/Tests/Datadog/DatadogTests.swift | 22 +++++++++++++++++++ .../Sources/Context/DatadogSite.swift | 3 ++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index f90f615e43..95802e7329 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -23,7 +23,7 @@ internal final class RemoteConfigurationCache { /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. /// `nil` when no cache exists yet (first launch, or remoteConfigurationID was never set). - /// Consumed by the config-application layer in a follow-on ticket (out of scope for RUM-16084). + /// Consumed by the config-application layer once parsing and applying remote values is implemented. private(set) var data: Data? init(directory: Directory) { diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 0c701570a8..58752918d4 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -301,8 +301,11 @@ public enum Datadog { // Trigger remote config fetch if an ID was provided. // The fetch is async — init returns immediately and does not wait for it. - if let id = configuration.remoteConfigurationID, !id.isEmpty { - if let endpoint = configuration.site.remoteConfigurationURL(for: id) { + if let rawID = configuration.remoteConfigurationID { + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + if id.isEmpty { + core.telemetry.error("[RemoteConfig] remoteConfigurationID must not be blank") + } else if let endpoint = configuration.site.remoteConfigurationURL(for: id) { let session = configuration.remoteConfigurationSession ?? { let sessionConfig: URLSessionConfiguration = .ephemeral sessionConfig.urlCache = nil diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index 161575ca78..dd901503f2 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -574,6 +574,28 @@ class DatadogTests: XCTestCase { waitForExpectations(timeout: 0.5) } + func testGivenEmptyRemoteConfigurationID_fetchIsSkipped() { + // Given — whitespace-only ID must be treated as empty and skip the fetch + let noFetchExpectation = expectation(description: "no remote config fetch should occur") + noFetchExpectation.isInverted = true + RemoteConfigMockURLProtocol.requestHandler = { _ in + noFetchExpectation.fulfill() + throw URLError(.cancelled) + } + var config = defaultConfig + config.remoteConfigurationID = " \n " + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] + config.remoteConfigurationSession = URLSession(configuration: sessionConfig) + + // When + Datadog.initialize(with: config, trackingConsent: .granted) + defer { Datadog.flushAndDeinitialize() } + + // Then — the inverted expectation times out (i.e. passes) if no request is fired. + waitForExpectations(timeout: 0.5) + } + func testGivenRemoteConfigurationID_fetchIsTriggered() { // Given let fetchExpectation = expectation(description: "remote config fetch triggered") diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 7e890661e9..3d2050e982 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -51,7 +51,8 @@ extension DatadogSite { /// Constructs the CDN URL for fetching the remote configuration document. /// - Parameter id: The value of `Datadog.Configuration.remoteConfigurationID`. - /// - Returns: URL to GET the config JSON from, or `nil` if `id` cannot be percent-encoded. + /// - Returns: URL to GET the config JSON from, or `nil` if `id` cannot be percent-encoded + /// or if the resulting URL string is malformed. @_spi(Internal) public func remoteConfigurationURL(for id: String) -> URL? { // Format: https://sdk-configuration.browser-intake-{site}/v1/{id}.json From 64852243030a257813a5ef646be6a170fff91520 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Thu, 14 May 2026 09:35:59 +0200 Subject: [PATCH 10/16] [RUM-16084] Remove force-unwrap from remoteConfigurationURL --- DatadogInternal/Sources/Context/DatadogSite.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 3d2050e982..efdf90a2bd 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -60,10 +60,12 @@ extension DatadogSite { // Subtract them so an ID containing those characters doesn't produce extra path // segments, a query string, or a fragment. let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#")) - guard let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { + guard + let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed), + let host = endpoint.host + else { return nil } - // swiftlint:disable:next force_unwrapping - return URL(string: "https://sdk-configuration.\(endpoint.host!)/v1/\(encoded).json") + return URL(string: "https://sdk-configuration.\(host)/v1/\(encoded).json") } } From 895b3302861fd6062e8cb5fa0ac3a30daa3b3a77 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Tue, 19 May 2026 12:34:45 +0200 Subject: [PATCH 11/16] [RUM-16084] Address maxep PR feedback: id-based cache, loadError, convenience init, deferred schema validation --- DatadogCore/Sources/Core/DatadogCore.swift | 28 ++++-------- .../Core/RemoteConfigurationCache.swift | 35 +++++++++------ .../Core/RemoteConfigurationFetcher.swift | 27 ++++++----- DatadogCore/Sources/Datadog.swift | 45 +++++++++++++------ .../Core/RemoteConfigurationCacheTests.swift | 42 +++++++++++++---- .../RemoteConfigurationFetcherTests.swift | 35 ++++++++------- 6 files changed, 130 insertions(+), 82 deletions(-) diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 9d8b863039..33e1c0f80c 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -56,11 +56,10 @@ internal final class DatadogCore { let bus = MessageBus() /// Cache of the remote configuration JSON fetched from the CDN. - /// Created at init using this core's directory. `data` is `nil` on first - /// launch (or when `remoteConfigurationID` was never set); populated either - /// from disk (previous successful fetch) or in-memory after the first - /// successful fetch in the current session. - private let remoteConfigCache: RemoteConfigurationCache + /// `nil` when `remoteConfigurationID` was not set at init. + /// `data` is `nil` on first launch; populated from disk (previous successful + /// fetch) or in-memory after the first successful fetch in the current session. + internal let remoteConfigCache: RemoteConfigurationCache? /// Registry for Features. @ReadWriteLock @@ -93,6 +92,7 @@ internal final class DatadogCore { /// - encryption: The on-disk data encryption. /// - contextProvider: The core context provider. /// - applicationVersion: The application version. + /// - remoteConfigCache: Pre-built cache for remote configuration; `nil` when no ID was configured. init( directory: CoreDirectory, dateProvider: DateProvider, @@ -104,7 +104,8 @@ internal final class DatadogCore { applicationVersion: String, maxBatchesPerUpload: Int, backgroundTasksEnabled: Bool, - isRunFromExtension: Bool = false + isRunFromExtension: Bool = false, + remoteConfigCache: RemoteConfigurationCache? = nil ) { self.directory = directory self.dateProvider = dateProvider @@ -121,7 +122,7 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - self.remoteConfigCache = RemoteConfigurationCache(directory: directory.coreDirectory) + self.remoteConfigCache = remoteConfigCache // connect the core to the message bus. // the bus will keep a weak ref to the core. bus.connect(core: self) @@ -262,19 +263,6 @@ internal final class DatadogCore { allDataStores.forEach { $0.clearAllData() } } - /// Fetches the remote configuration document from the CDN and caches it to disk. - /// - /// Called from `Datadog.initialize()` when `remoteConfigurationID` is set. - /// The fetch is fire-and-forget — SDK init does not wait for it. - internal func fetchRemoteConfiguration(from endpoint: URL, session: URLSession) { - let fetcher = RemoteConfigurationFetcher( - cache: remoteConfigCache, - telemetry: telemetry, - session: session - ) - fetcher.fetch(from: endpoint) - } - /// Adds a message receiver to the bus. /// /// After being added to the bus, the core will send the current context to receiver. diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index 95802e7329..86da4cb1ac 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -9,39 +9,46 @@ import DatadogInternal /// Manages the on-disk cache of the remote configuration JSON document. /// -/// The cache is a single file `remote-config.json` stored at the root of -/// the SDK's private core directory: +/// The cache is a single file named after the remote configuration ID, stored at +/// the root of the SDK's private core directory: /// -/// /Library/Caches/com.datadoghq/v2//remote-config.json +/// /Library/Caches/com.datadoghq/v2//.json /// /// The file contains raw JSON bytes exactly as received from the CDN. /// Parsing and applying those values is handled separately. internal final class RemoteConfigurationCache { - private static let fileName = "remote-config.json" - private let fileURL: URL /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. - /// `nil` when no cache exists yet (first launch, or remoteConfigurationID was never set). + /// `nil` when no cache exists yet (first launch, or no file on disk). /// Consumed by the config-application layer once parsing and applying remote values is implemented. private(set) var data: Data? - init(directory: Directory) { - self.fileURL = directory.url.appendingPathComponent(Self.fileName) + /// Error encountered when reading the cache file at init, if any. + /// `nil` when the file was absent (expected on first launch) or read successfully. + private(set) var loadError: Error? + + init(id: String, directory: Directory) { + self.fileURL = directory.url.appendingPathComponent("\(id).json") // Synchronous read on the caller's thread (main thread during SDK init). // Acceptable because the file is small (a single JSON document) and only // present after a previous successful fetch — absent on first launch. - self.data = Self.readFromDisk(at: fileURL) + let (data, error) = Self.readFromDisk(at: fileURL) + self.data = data + self.loadError = error } // MARK: - Private - private static func readFromDisk(at url: URL) -> Data? { - guard FileManager.default.fileExists(atPath: url.path) - else { - return nil + private static func readFromDisk(at url: URL) -> (Data?, Error?) { + guard FileManager.default.fileExists(atPath: url.path) else { + return (nil, nil) + } + do { + return (try Data(contentsOf: url), nil) + } catch { + return (nil, error) } - return try? Data(contentsOf: url) } // MARK: - Internal diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift index 2021e6fef9..181801a3e6 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift @@ -12,7 +12,7 @@ import DatadogInternal /// /// Rules: /// - Fetch is always asynchronous — never blocks the caller. -/// - On success (2xx, non-empty body, valid JSON): calls `cache.save(_:)`. +/// - On success (2xx, non-empty body): calls `cache.save(_:)`. /// - On any failure: reports a telemetry error and leaves the existing cache untouched. internal final class RemoteConfigurationFetcher { private let cache: RemoteConfigurationCache @@ -22,19 +22,32 @@ internal final class RemoteConfigurationFetcher { init( cache: RemoteConfigurationCache, telemetry: Telemetry, - session: URLSession = URLSession(configuration: .ephemeral) + session: URLSession ) { self.cache = cache self.telemetry = telemetry self.session = session } + convenience init( + cache: RemoteConfigurationCache, + connectionProxyDictionary: [AnyHashable: Any]?, + telemetry: Telemetry + ) { + let config = URLSessionConfiguration.ephemeral + config.urlCache = nil + config.connectionProxyDictionary = connectionProxyDictionary + self.init(cache: cache, telemetry: telemetry, session: URLSession(configuration: config)) + } + /// Fires a background GET request to `endpoint`. /// /// - Parameter endpoint: The CDN URL to fetch from. /// - Parameter didComplete: Called when the fetch (and any write) is done. /// Pass `nil` in production; inject a closure in tests to await completion. func fetch(from endpoint: URL, didComplete: (() -> Void)? = nil) { + // TODO RUM-16386: Add ETag-based conditional requests (If-None-Match / 304) and + // TTL-based revalidation (skip fetch if cached config is < 5 min old). let cache = self.cache let telemetry = self.telemetry let task = session.dataTask(with: endpoint) { data, response, error in @@ -66,15 +79,7 @@ internal final class RemoteConfigurationFetcher { return } - // 4. Invalid JSON - // Intentional allocate-and-discard: we only need to validate the bytes - // are well-formed JSON before caching. The parsed object is thrown away. - // This guarantees the cache never contains non-JSON data, so future - // parsing layers can trust the cached bytes without re-validating. - guard (try? JSONSerialization.jsonObject(with: data)) != nil else { - telemetry.error("[RemoteConfig] Response is not valid JSON") - return - } + // TODO RUM-16387: Validate the schema before saving // All checks passed — persist to disk if !cache.save(data) { diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 58752918d4..6fe503e6ca 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -299,6 +299,11 @@ public enum Datadog { CoreRegistry.register(core, named: instanceName) + // Report any error encountered while reading the on-disk cache at init. + if let loadError = core.remoteConfigCache?.loadError { + core.telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: loadError) + } + // Trigger remote config fetch if an ID was provided. // The fetch is async — init returns immediately and does not wait for it. if let rawID = configuration.remoteConfigurationID { @@ -306,13 +311,16 @@ public enum Datadog { if id.isEmpty { core.telemetry.error("[RemoteConfig] remoteConfigurationID must not be blank") } else if let endpoint = configuration.site.remoteConfigurationURL(for: id) { - let session = configuration.remoteConfigurationSession ?? { - let sessionConfig: URLSessionConfiguration = .ephemeral - sessionConfig.urlCache = nil - sessionConfig.connectionProxyDictionary = configuration.proxyConfiguration - return URLSession(configuration: sessionConfig) - }() - core.fetchRemoteConfiguration(from: endpoint, session: session) + // Cache is always non-nil here: the convenience init creates it for any non-empty ID. + if let cache = core.remoteConfigCache { + let fetcher: RemoteConfigurationFetcher + if let session = configuration.remoteConfigurationSession { + fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: core.telemetry, session: session) + } else { + fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: core.telemetry) + } + fetcher.fetch(from: endpoint) + } } else { core.telemetry.error("[RemoteConfig] Could not build CDN URL for remoteConfigurationID '\(id)'") } @@ -436,12 +444,22 @@ extension DatadogCore { ) let isRunFromExtension = bundleType == .iOSAppExtension + let directory = try CoreDirectory( + in: configuration.systemDirectory(), + instanceName: instanceName, + site: configuration.site + ) + + let remoteConfigCache: RemoteConfigurationCache? + if let rawID = configuration.remoteConfigurationID { + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + remoteConfigCache = id.isEmpty ? nil : RemoteConfigurationCache(id: id, directory: directory.coreDirectory) + } else { + remoteConfigCache = nil + } + self.init( - directory: try CoreDirectory( - in: configuration.systemDirectory(), - instanceName: instanceName, - site: configuration.site - ), + directory: directory, dateProvider: configuration.dateProvider, initialConsent: trackingConsent, performance: performance, @@ -478,7 +496,8 @@ extension DatadogCore { applicationVersion: applicationVersion, maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled, - isRunFromExtension: isRunFromExtension + isRunFromExtension: isRunFromExtension, + remoteConfigCache: remoteConfigCache ) telemetry.configuration( diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift index 60b7993016..93dd62d211 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift @@ -25,8 +25,9 @@ class RemoteConfigurationCacheTests: XCTestCase { // MARK: Read at init func testReturnsNilWhenNoCacheExists() { - let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) XCTAssertNil(cache.data) + XCTAssertNil(cache.loadError) } // MARK: Persistence across instances (simulates app relaunch) @@ -35,35 +36,60 @@ class RemoteConfigurationCacheTests: XCTestCase { let payload = Data("{\"session_sample_rate\":50}".utf8) // First "launch": save data - let cache1 = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let cache1 = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) cache1.save(payload) // Second "launch": a fresh instance must read it back - let cache2 = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let cache2 = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) XCTAssertEqual(cache2.data, payload) + XCTAssertNil(cache2.loadError) } func testSaveOverwritesPreviousFile() { let first = Data("{\"v\":1}".utf8) let second = Data("{\"v\":2}".utf8) - RemoteConfigurationCache(directory: coreDir.coreDirectory).save(first) - RemoteConfigurationCache(directory: coreDir.coreDirectory).save(second) + RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).save(first) + RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).save(second) - let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) XCTAssertEqual(cache.data, second) } + func testDifferentIDsUseDifferentFiles() { + let payload1 = Data("{\"v\":1}".utf8) + let payload2 = Data("{\"v\":2}".utf8) + + RemoteConfigurationCache(id: "id-one", directory: coreDir.coreDirectory).save(payload1) + RemoteConfigurationCache(id: "id-two", directory: coreDir.coreDirectory).save(payload2) + + XCTAssertEqual(RemoteConfigurationCache(id: "id-one", directory: coreDir.coreDirectory).data, payload1) + XCTAssertEqual(RemoteConfigurationCache(id: "id-two", directory: coreDir.coreDirectory).data, payload2) + } + // MARK: Failure resilience func testSaveReturnsTrueOnSuccess() { - let cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) XCTAssertTrue(cache.save(Data("{\"k\":\"v\"}".utf8))) } func testSaveReturnsFalseWhenDirectoryMissing() { let missing = Directory(url: URL(fileURLWithPath: "/no/such/path/")) - let cache = RemoteConfigurationCache(directory: missing) + let cache = RemoteConfigurationCache(id: "test-id", directory: missing) XCTAssertFalse(cache.save(Data("{\"k\":\"v\"}".utf8))) } + + func testLoadErrorSetWhenFileIsCorrupt() throws { + // Write a valid file, then revoke read permission so Data(contentsOf:) fails. + let id = "corrupt-id" + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("\(id).json") + try Data("{\"v\":1}".utf8).write(to: fileURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fileURL.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) } + + let cache = RemoteConfigurationCache(id: id, directory: coreDir.coreDirectory) + XCTAssertNil(cache.data) + XCTAssertNotNil(cache.loadError) + } } diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift index 7f4f99ccb5..651a94e65c 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -63,7 +63,7 @@ class RemoteConfigurationFetcherTests: XCTestCase { override func setUp() { coreDir = temporaryUniqueCoreDirectory() coreDir.create() - cache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) } override func tearDown() { @@ -89,7 +89,7 @@ class RemoteConfigurationFetcherTests: XCTestCase { fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) wait(for: [expectation], timeout: 2) - let freshCache = RemoteConfigurationCache(directory: coreDir.coreDirectory) + let freshCache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) XCTAssertEqual(freshCache.data, payload, "Cache must contain the CDN response") XCTAssertFalse( telemetry.messages.contains { @@ -117,7 +117,7 @@ class RemoteConfigurationFetcherTests: XCTestCase { wait(for: [expectation], timeout: 2) XCTAssertEqual( - RemoteConfigurationCache(directory: coreDir.coreDirectory).data, + RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, existing, "Existing cache must be preserved after a network error" ) @@ -146,7 +146,7 @@ class RemoteConfigurationFetcherTests: XCTestCase { fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) wait(for: [expectation], timeout: 2) - XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) + XCTAssertEqual(RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, existing) XCTAssertTrue( telemetry.messages.contains { if case .error = $0 { @@ -172,7 +172,7 @@ class RemoteConfigurationFetcherTests: XCTestCase { fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) wait(for: [expectation], timeout: 2) - XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) + XCTAssertEqual(RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, existing) XCTAssertTrue( telemetry.messages.contains { if case .error = $0 { @@ -184,12 +184,13 @@ class RemoteConfigurationFetcherTests: XCTestCase { ) } - func testInvalidJSONDoesNotOverwriteExistingCache() { - let existing = Data("{\"v\":1}".utf8) - cache.save(existing) + func testNonJSONBodyIsSavedToCache() { + // JSON schema validation is deferred (TODO RUM-16387), so any non-empty 2xx + // body is accepted and written to cache as-is. + let nonJSON = Data("this is not json".utf8) MockURLProtocol.requestHandler = { request in - (okResponse(for: request.url!), Data("this is not json".utf8)) + (okResponse(for: request.url!), nonJSON) } let telemetry = TelemetryMock() @@ -198,22 +199,24 @@ class RemoteConfigurationFetcherTests: XCTestCase { fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) wait(for: [expectation], timeout: 2) - XCTAssertEqual(RemoteConfigurationCache(directory: coreDir.coreDirectory).data, existing) - XCTAssertTrue( + XCTAssertEqual( + RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, + nonJSON, + "Non-JSON body must be saved until schema validation is implemented" + ) + XCTAssertFalse( telemetry.messages.contains { - if case .error = $0 { - return true - } + if case .error = $0 { return true } return false }, - "A telemetry error must be reported" + "No telemetry error expected when body is non-empty and status is 2xx" ) } func testDiskWriteFailureReportsTelemetry() { // Use a cache pointing at a non-existent directory so the write will fail let missingDir = Directory(url: URL(fileURLWithPath: "/no/such/path/")) - let brokenCache = RemoteConfigurationCache(directory: missingDir) + let brokenCache = RemoteConfigurationCache(id: "test-id", directory: missingDir) MockURLProtocol.requestHandler = { request in (okResponse(for: request.url!), Data("{}".utf8)) From 2bd0fcb967f8f17dee22601aeb9c996eeff518da Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Tue, 19 May 2026 12:43:30 +0200 Subject: [PATCH 12/16] [RUM-16084] Fix lint: conditional_returns_on_newline in FetcherTests --- .../Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift index 651a94e65c..e34faed8d9 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -206,7 +206,9 @@ class RemoteConfigurationFetcherTests: XCTestCase { ) XCTAssertFalse( telemetry.messages.contains { - if case .error = $0 { return true } + if case .error = $0 { + return true + } return false }, "No telemetry error expected when body is non-empty and status is 2xx" From ac2ac8b8445e4cb76fc0861a9349642ae5b5c19a Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 20 May 2026 16:22:31 +0200 Subject: [PATCH 13/16] [RUM-16084] RemoteConfiguration object, inject URLSession, remove ID sanitization, propagate save errors --- DatadogCore/Sources/Core/DatadogCore.swift | 12 +- .../Sources/Core/RemoteConfiguration.swift | 40 ++++++ .../Core/RemoteConfigurationCache.swift | 8 +- .../Core/RemoteConfigurationFetcher.swift | 4 +- DatadogCore/Sources/Datadog.swift | 39 +----- .../Sources/DatadogConfiguration.swift | 8 +- .../Core/RemoteConfigurationCacheTests.swift | 8 +- .../Core/RemoteConfigurationTests.swift | 126 ++++++++++++++++++ DatadogCore/Tests/Datadog/DatadogTests.swift | 87 ++---------- 9 files changed, 197 insertions(+), 135 deletions(-) create mode 100644 DatadogCore/Sources/Core/RemoteConfiguration.swift create mode 100644 DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 33e1c0f80c..825ce9f0a8 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,11 +55,9 @@ internal final class DatadogCore { /// The message-bus instance. let bus = MessageBus() - /// Cache of the remote configuration JSON fetched from the CDN. + /// Owns the remote configuration cache and fetch lifecycle. /// `nil` when `remoteConfigurationID` was not set at init. - /// `data` is `nil` on first launch; populated from disk (previous successful - /// fetch) or in-memory after the first successful fetch in the current session. - internal let remoteConfigCache: RemoteConfigurationCache? + internal let remoteConfiguration: RemoteConfiguration? /// Registry for Features. @ReadWriteLock @@ -92,7 +90,7 @@ internal final class DatadogCore { /// - encryption: The on-disk data encryption. /// - contextProvider: The core context provider. /// - applicationVersion: The application version. - /// - remoteConfigCache: Pre-built cache for remote configuration; `nil` when no ID was configured. + /// - remoteConfigurationID: The remote configuration ID; `nil` when not configured. init( directory: CoreDirectory, dateProvider: DateProvider, @@ -105,7 +103,7 @@ internal final class DatadogCore { maxBatchesPerUpload: Int, backgroundTasksEnabled: Bool, isRunFromExtension: Bool = false, - remoteConfigCache: RemoteConfigurationCache? = nil + remoteConfigurationID: String? = nil ) { self.directory = directory self.dateProvider = dateProvider @@ -122,7 +120,7 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - self.remoteConfigCache = remoteConfigCache + self.remoteConfiguration = remoteConfigurationID.map { RemoteConfiguration(id: $0, directory: directory.coreDirectory) } // connect the core to the message bus. // the bus will keep a weak ref to the core. bus.connect(core: self) diff --git a/DatadogCore/Sources/Core/RemoteConfiguration.swift b/DatadogCore/Sources/Core/RemoteConfiguration.swift new file mode 100644 index 0000000000..d2186ac8f7 --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteConfiguration.swift @@ -0,0 +1,40 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Owns the remote configuration cache and fetch lifecycle. +/// +/// Created by `DatadogCore` when a `remoteConfigurationID` is provided. +/// Call `start(from:connectionProxyDictionary:telemetry:)` once the core is +/// registered to fire the initial CDN fetch. +/// +/// TODO: Also trigger `start()` on every app foreground transition so remote config +/// is refreshed while the app is in use, not only at SDK init (RFC §Caching Strategy). +internal final class RemoteConfiguration { + let cache: RemoteConfigurationCache + + init(id: String, directory: Directory) { + self.cache = RemoteConfigurationCache(id: id, directory: directory) + } + + /// Reports any load error from the previous session and fires an async CDN fetch. + /// + /// - Parameter session: Injected only in tests; pass `nil` in production. + func start(from endpoint: URL, connectionProxyDictionary: [AnyHashable: Any]?, telemetry: Telemetry, session: URLSession? = nil) { + if let error = cache.loadError { + telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: error) + } + let fetcher: RemoteConfigurationFetcher + if let session = session { + fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: session) + } else { + fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: connectionProxyDictionary, telemetry: telemetry) + } + fetcher.fetch(from: endpoint) + } +} diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift index 86da4cb1ac..d569b4550f 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift @@ -57,17 +57,17 @@ internal final class RemoteConfigurationCache { /// Called only on a successful CDN response — never on failure. /// In-memory `data` is only updated when the disk write succeeds, keeping /// the two in sync. - /// - Returns: `true` if the write succeeded, `false` otherwise. + /// - Returns: `nil` on success, or the underlying write error on failure. @discardableResult - func save(_ data: Data) -> Bool { + func save(_ data: Data) -> Error? { do { try data.write(to: fileURL, options: .atomic) self.data = data - return true + return nil } catch { // self.data is intentionally NOT updated so in-memory state stays // consistent with what is actually on disk. - return false + return error } } } diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift index 181801a3e6..63b2885014 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift @@ -82,8 +82,8 @@ internal final class RemoteConfigurationFetcher { // TODO RUM-16387: Validate the schema before saving // All checks passed — persist to disk - if !cache.save(data) { - telemetry.error("[RemoteConfig] Failed to write remote configuration to disk") + if let error = cache.save(data) { + telemetry.error("[RemoteConfig] Failed to write remote configuration to disk", error: error) } } task.resume() diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 6fe503e6ca..9fce8a4921 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -299,31 +299,10 @@ public enum Datadog { CoreRegistry.register(core, named: instanceName) - // Report any error encountered while reading the on-disk cache at init. - if let loadError = core.remoteConfigCache?.loadError { - core.telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: loadError) - } - - // Trigger remote config fetch if an ID was provided. - // The fetch is async — init returns immediately and does not wait for it. - if let rawID = configuration.remoteConfigurationID { - let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) - if id.isEmpty { - core.telemetry.error("[RemoteConfig] remoteConfigurationID must not be blank") - } else if let endpoint = configuration.site.remoteConfigurationURL(for: id) { - // Cache is always non-nil here: the convenience init creates it for any non-empty ID. - if let cache = core.remoteConfigCache { - let fetcher: RemoteConfigurationFetcher - if let session = configuration.remoteConfigurationSession { - fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: core.telemetry, session: session) - } else { - fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: core.telemetry) - } - fetcher.fetch(from: endpoint) - } - } else { - core.telemetry.error("[RemoteConfig] Could not build CDN URL for remoteConfigurationID '\(id)'") - } + if let id = configuration.remoteConfigurationID, + let endpoint = configuration.site.remoteConfigurationURL(for: id) { + // remoteConfiguration is always non-nil here — DatadogCore creates it for any non-nil remoteConfigurationID. + core.remoteConfiguration?.start(from: endpoint, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: core.telemetry) } deleteV1Folders(in: core) @@ -450,14 +429,6 @@ extension DatadogCore { site: configuration.site ) - let remoteConfigCache: RemoteConfigurationCache? - if let rawID = configuration.remoteConfigurationID { - let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) - remoteConfigCache = id.isEmpty ? nil : RemoteConfigurationCache(id: id, directory: directory.coreDirectory) - } else { - remoteConfigCache = nil - } - self.init( directory: directory, dateProvider: configuration.dateProvider, @@ -497,7 +468,7 @@ extension DatadogCore { maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled, isRunFromExtension: isRunFromExtension, - remoteConfigCache: remoteConfigCache + remoteConfigurationID: configuration.remoteConfigurationID ) telemetry.configuration( diff --git a/DatadogCore/Sources/DatadogConfiguration.swift b/DatadogCore/Sources/DatadogConfiguration.swift index abbfda9d31..1f967deeda 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -130,6 +130,9 @@ extension Datadog { /// raw JSON to disk for use on subsequent launches. /// /// Default is `nil` — no fetch is performed. + /// + /// RFC also specifies a `requireRemoteConfiguration` flag: when `true` and no cache + /// exists, `Datadog.initialize` should return without starting the SDK. Not yet implemented. public var remoteConfigurationID: String? = nil /// Creates a Datadog SDK Configuration object. @@ -244,10 +247,5 @@ extension Datadog { /// The default application state provider for accessing [application state](https://developer.apple.com/documentation/uikit/uiapplication/state). internal var appStateProvider: AppStateProvider = DefaultAppStateProvider() - - /// Override the URLSession used for remote configuration fetching. For tests only. - /// When `nil` (the default), `Datadog.initialize()` builds a session from - /// `proxyConfiguration` — matching the proxy behaviour of the main HTTP client. - internal var remoteConfigurationSession: URLSession? = nil } } diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift index 93dd62d211..cbbb0bac5e 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift @@ -69,15 +69,15 @@ class RemoteConfigurationCacheTests: XCTestCase { // MARK: Failure resilience - func testSaveReturnsTrueOnSuccess() { + func testSaveReturnsNilOnSuccess() { let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertTrue(cache.save(Data("{\"k\":\"v\"}".utf8))) + XCTAssertNil(cache.save(Data("{\"k\":\"v\"}".utf8))) } - func testSaveReturnsFalseWhenDirectoryMissing() { + func testSaveReturnsErrorWhenDirectoryMissing() { let missing = Directory(url: URL(fileURLWithPath: "/no/such/path/")) let cache = RemoteConfigurationCache(id: "test-id", directory: missing) - XCTAssertFalse(cache.save(Data("{\"k\":\"v\"}".utf8))) + XCTAssertNotNil(cache.save(Data("{\"k\":\"v\"}".utf8))) } func testLoadErrorSetWhenFileIsCorrupt() throws { diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift new file mode 100644 index 0000000000..273a76d54a --- /dev/null +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift @@ -0,0 +1,126 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +@testable import DatadogCore + +// MARK: - MockURLProtocol + +private class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { client?.urlProtocol(self, didLoad: data) } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private func mockSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) +} + +// MARK: - Tests + +class RemoteConfigurationTests: XCTestCase { + private var coreDir: CoreDirectory! // swiftlint:disable:this implicitly_unwrapped_optional + private var session: URLSession? + + override func setUp() { + super.setUp() + coreDir = temporaryUniqueCoreDirectory() + coreDir.create() + session = mockSession() + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + session?.invalidateAndCancel() + session = nil + coreDir.delete() + super.tearDown() + } + + // MARK: Init + + func testInitCreatesCacheForGivenID() { + let rc = RemoteConfiguration(id: "abc", directory: coreDir.coreDirectory) + // Cache is created — no data yet on first launch, no error either. + XCTAssertNil(rc.cache.data) + XCTAssertNil(rc.cache.loadError) + } + + // MARK: start() + + func testStartReportsLoadErrorViaTelemetry() throws { + // Given — write a file then revoke read permission so the cache init fails to read it. + let id = "error-id" + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("\(id).json") + try Data("{\"v\":1}".utf8).write(to: fileURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fileURL.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) } + + let rc = RemoteConfiguration(id: id, directory: coreDir.coreDirectory) + XCTAssertNotNil(rc.cache.loadError, "Precondition: cache must have a loadError") + + // When + let telemetry = TelemetryMock() + rc.start(from: URL(string: "https://example.com")!, connectionProxyDictionary: nil, telemetry: telemetry, session: session) + + // Then + XCTAssertTrue( + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, + "start() must report the load error via telemetry" + ) + } + + func testStartWithNoLoadErrorReportsNoTelemetryError() { + // Given — fresh cache, no previous file on disk. + // Return a successful response so the async fetch never reports an error regardless of timing. + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) + } + let rc = RemoteConfiguration(id: "clean-id", directory: coreDir.coreDirectory) + XCTAssertNil(rc.cache.loadError, "Precondition: no load error expected") + + // When + let telemetry = TelemetryMock() + rc.start(from: URL(string: "https://example.com")!, connectionProxyDictionary: nil, telemetry: telemetry, session: session) + + // Then — no telemetry error from the load path + XCTAssertFalse( + telemetry.messages.contains { + if case .error = $0 { + return true + } + return false + }, + "start() must not report a telemetry error when there is no load error" + ) + } +} diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index dd901503f2..0876420589 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -12,32 +12,6 @@ import TestUtilities @testable import DatadogTrace @testable import DatadogCore -// MARK: RemoteConfigMockURLProtocol - -private class RemoteConfigMockURLProtocol: URLProtocol { - static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let handler = RemoteConfigMockURLProtocol.requestHandler else { - client?.urlProtocolDidFinishLoading(self) - return - } - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - if let data = data { client?.urlProtocol(self, didLoad: data) } - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} - // MARK: DatadogTests class DatadogTests: XCTestCase { @@ -55,7 +29,6 @@ class DatadogTests: XCTestCase { override func tearDown() { consolePrint = { message, _ in print(message) } printFunction = nil - RemoteConfigMockURLProtocol.requestHandler = nil XCTAssertFalse(Datadog.isInitialized()) super.tearDown() } @@ -552,72 +525,28 @@ class DatadogTests: XCTestCase { // MARK: Remote Configuration - func testGivenNoRemoteConfigurationID_fetchIsSkipped() { - // Given — inject a session that fulfils an inverted expectation if called - let noFetchExpectation = expectation(description: "no remote config fetch should occur") - noFetchExpectation.isInverted = true - RemoteConfigMockURLProtocol.requestHandler = { _ in - noFetchExpectation.fulfill() - throw URLError(.cancelled) - } - var config = defaultConfig - // remoteConfigurationID is nil by default - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] - config.remoteConfigurationSession = URLSession(configuration: sessionConfig) - - // When - Datadog.initialize(with: config, trackingConsent: .granted) - defer { Datadog.flushAndDeinitialize() } - - // Then — the inverted expectation times out (i.e. passes) if no request is fired. - waitForExpectations(timeout: 0.5) - } - - func testGivenEmptyRemoteConfigurationID_fetchIsSkipped() { - // Given — whitespace-only ID must be treated as empty and skip the fetch - let noFetchExpectation = expectation(description: "no remote config fetch should occur") - noFetchExpectation.isInverted = true - RemoteConfigMockURLProtocol.requestHandler = { _ in - noFetchExpectation.fulfill() - throw URLError(.cancelled) - } - var config = defaultConfig - config.remoteConfigurationID = " \n " - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] - config.remoteConfigurationSession = URLSession(configuration: sessionConfig) - + func testGivenNoRemoteConfigurationID_cacheIsNotCreated() throws { // When - Datadog.initialize(with: config, trackingConsent: .granted) + Datadog.initialize(with: defaultConfig, trackingConsent: .granted) defer { Datadog.flushAndDeinitialize() } - // Then — the inverted expectation times out (i.e. passes) if no request is fired. - waitForExpectations(timeout: 0.5) + // Then + let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) + XCTAssertNil(core.remoteConfiguration) } - func testGivenRemoteConfigurationID_fetchIsTriggered() { + func testGivenRemoteConfigurationID_remoteConfigurationIsCreated() throws { // Given - let fetchExpectation = expectation(description: "remote config fetch triggered") - RemoteConfigMockURLProtocol.requestHandler = { request in - fetchExpectation.fulfill() - return ( - HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, - Data("{}".utf8) - ) - } var config = defaultConfig config.remoteConfigurationID = "test-id" - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [RemoteConfigMockURLProtocol.self] - config.remoteConfigurationSession = URLSession(configuration: sessionConfig) // When Datadog.initialize(with: config, trackingConsent: .granted) defer { Datadog.flushAndDeinitialize() } // Then - waitForExpectations(timeout: 5) + let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) + XCTAssertNotNil(core.remoteConfiguration) } func testCustomSDKInstance() throws { From 2746d0a270f39b6fd8ef1c34a73df0e15dbdd30a Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Wed, 20 May 2026 16:42:21 +0200 Subject: [PATCH 14/16] [RUM-16084] Register RemoteConfiguration.swift and RemoteConfigurationTests.swift in Xcode project --- Datadog/Datadog.xcodeproj/project.pbxproj | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 976c8fbe50..a656d5d94f 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -23,6 +23,8 @@ 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */; }; 1019DFBB2FA3910A006599B4 /* RemoteConfigurationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */; }; 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */; }; + 10E9062A2FBDB81A002D5F45 /* RemoteConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */; }; + 10E9062C2FBDBA4B002D5F45 /* RemoteConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */; }; 11030D5F2D959EAD00732D5F /* DatadogLogs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D207317C29A5226A00ECBF94 /* DatadogLogs.framework */; }; 11030D6D2D95A48B00732D5F /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; }; 11030D772D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */; }; @@ -406,8 +408,6 @@ 61054E8D2A6EE10A00AAA894 /* RUMContextReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */; }; 61054E8E2A6EE10A00AAA894 /* SRContextPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */; }; 61054E8F2A6EE10A00AAA894 /* SegmentRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E412A6EE10A00AAA894 /* SegmentRequestBuilder.swift */; }; - 6AF71E3149694FD0972B41C5 /* RecordingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0DE1D82F8B4F7FA24BD2C9 /* RecordingController.swift */; }; - B5B633BD7EC649AC9F29F246 /* RecordingComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 333D0D8EAED64F559835F384 /* RecordingComponents.swift */; }; 61054E942A6EE10A00AAA894 /* TextObfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E4A2A6EE10A00AAA894 /* TextObfuscator.swift */; }; 61054E952A6EE10A00AAA894 /* SnapshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E4B2A6EE10A00AAA894 /* SnapshotProcessor.swift */; }; 61054E962A6EE10A00AAA894 /* Diff+SRWireframes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E4D2A6EE10A00AAA894 /* Diff+SRWireframes.swift */; }; @@ -683,6 +683,7 @@ 61F930CB2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; 61FC5F3525CC1898006BB4DE /* CrashContextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FC5F3425CC1898006BB4DE /* CrashContextProviderTests.swift */; }; 6482FB0E2EE885C60042234B /* FlagsClientInternal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6482FB0D2EE885C20042234B /* FlagsClientInternal.swift */; }; + 6AF71E3149694FD0972B41C5 /* RecordingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0DE1D82F8B4F7FA24BD2C9 /* RecordingController.swift */; }; 770E0BF093842AF7D350E888 /* EvaluationLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC44AD2847746ED69201F65 /* EvaluationLoggingTests.swift */; }; 86092FDE2DEDC6830075D63B /* AccessibilityValuesMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86092FDC2DEDC6830075D63B /* AccessibilityValuesMock.swift */; }; 861FD6862DF2EAED00F59823 /* LocaleInfoPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861FD6842DF2EAED00F59823 /* LocaleInfoPublisherTests.swift */; }; @@ -797,6 +798,7 @@ B202DBC9FC73EEF0D237D306 /* HeatmapIdentifierComputationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F610A698379D300D06E515 /* HeatmapIdentifierComputationTests.swift */; }; B3E46CAB2D91B3AD00BABF66 /* NetworkContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */; }; B3E46CAE2D91B40000BABF66 /* NetworkContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E46CAD2D91B3FC00BABF66 /* NetworkContext.swift */; }; + B5B633BD7EC649AC9F29F246 /* RecordingComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 333D0D8EAED64F559835F384 /* RecordingComponents.swift */; }; CD91654B77EEEB08CD20B3B0 /* HeatmapIdentifierStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC78B982F5C5B87E754DF44 /* HeatmapIdentifierStoreTests.swift */; }; D2056C212BBFE05A0085BC76 /* WireframesBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2056C202BBFE05A0085BC76 /* WireframesBuilderTests.swift */; }; D20605A3287464F40047275C /* ContextValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A2287464F40047275C /* ContextValuePublisher.swift */; }; @@ -1780,6 +1782,8 @@ 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcherTests.swift; sourceTree = ""; }; 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcher.swift; sourceTree = ""; }; 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogSiteTests.swift; sourceTree = ""; }; + 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfiguration.swift; sourceTree = ""; }; + 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationTests.swift; sourceTree = ""; }; 11014EACF1FB9927DAD57822 /* EvaluationMocks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvaluationMocks.swift; sourceTree = ""; }; 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHitchesMetric.swift; sourceTree = ""; }; 110311752EF96ED000750DD4 /* DDLogs+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDLogs+apiTests.m"; sourceTree = ""; }; @@ -1949,6 +1953,7 @@ 269035A12E41F93F00F1A830 /* UserConfigurationContextMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigurationContextMocks.swift; sourceTree = ""; }; 269035A42E41FAA500F1A830 /* AccountConfigurationContextMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationContextMocks.swift; sourceTree = ""; }; 2B6A3B154836FEB32C07EB50 /* EvaluationAggregator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvaluationAggregator.swift; sourceTree = ""; }; + 333D0D8EAED64F559835F384 /* RecordingComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordingComponents.swift; sourceTree = ""; }; 3C08F9CF2C2D652D002B0FF2 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; 3C0CB3442C19A1ED003B0E9B /* WatchdogTerminationReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchdogTerminationReporter.swift; sourceTree = ""; }; 3C0D5DD62A543B3B00446CF9 /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; @@ -2113,8 +2118,6 @@ 61054E382A6EE10A00AAA894 /* ViewTreeRecordingContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewTreeRecordingContext.swift; sourceTree = ""; }; 61054E392A6EE10A00AAA894 /* NodeIDGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NodeIDGenerator.swift; sourceTree = ""; }; 61054E3A2A6EE10A00AAA894 /* WindowViewTreeSnapshotProducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowViewTreeSnapshotProducer.swift; sourceTree = ""; }; - 333D0D8EAED64F559835F384 /* RecordingComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordingComponents.swift; sourceTree = ""; }; - 7D0DE1D82F8B4F7FA24BD2C9 /* RecordingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordingController.swift; sourceTree = ""; }; 61054E3C2A6EE10A00AAA894 /* SessionReplayFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayFeature.swift; sourceTree = ""; }; 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RUMContextReceiver.swift; sourceTree = ""; }; 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRContextPublisher.swift; sourceTree = ""; }; @@ -2521,6 +2524,7 @@ 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifier.swift; sourceTree = ""; }; 6482FB0D2EE885C20042234B /* FlagsClientInternal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsClientInternal.swift; sourceTree = ""; }; 6FE4202BC457504AC37A51D6 /* HeatmapIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapIdentifier.swift; sourceTree = ""; }; + 7D0DE1D82F8B4F7FA24BD2C9 /* RecordingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordingController.swift; sourceTree = ""; }; 86092FDC2DEDC6830075D63B /* AccessibilityValuesMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityValuesMock.swift; sourceTree = ""; }; 861FD6842DF2EAED00F59823 /* LocaleInfoPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleInfoPublisherTests.swift; sourceTree = ""; }; 861FD6872DF3366400F59823 /* LocaleInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleInfo.swift; sourceTree = ""; }; @@ -4466,6 +4470,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( + 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */, 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */, 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, @@ -4594,6 +4599,7 @@ 61133C212423990D00786299 /* Core */ = { isa = PBXGroup; children = ( + 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */, 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */, 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */, 61DA8CAB2861C3720074A606 /* DirectoriesTests.swift */, @@ -7919,6 +7925,7 @@ 617699182A860D9D0030022B /* HTTPClient.swift in Sources */, D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */, 61133BD42423979B00786299 /* FileReader.swift in Sources */, + 10E9062A2FBDB81A002D5F45 /* RemoteConfiguration.swift in Sources */, D29294E0291D5ED100F8EFF9 /* ApplicationVersionPublisher.swift in Sources */, 61D3E0D9277B23F1008BE766 /* KronosNTPProtocol.swift in Sources */, 61D3E0DA277B23F1008BE766 /* KronosTimeFreeze.swift in Sources */, @@ -7982,6 +7989,7 @@ 3C0D5DE22A543DC400446CF9 /* EventGeneratorTests.swift in Sources */, 6136CB4A2A69C29C00AC265D /* FilesOrchestrator+MetricsTests.swift in Sources */, 266BFA5E2D6F4E31003041A5 /* AccountInfoPublisherTests.swift in Sources */, + 10E9062C2FBDBA4B002D5F45 /* RemoteConfigurationTests.swift in Sources */, D26C49AF2886DC7B00802B2D /* ApplicationStatePublisherTests.swift in Sources */, 9622235B2DFC233000D58EEC /* DDSwiftUIRUMActionsPredicateTests.swift in Sources */, 9622235C2DFC233000D58EEC /* DDSwiftUIRUMViewsPredicateTests.swift in Sources */, From 016a7d40bca90afc5c7cc35dcaf91f92d8280738 Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Fri, 22 May 2026 11:20:53 +0200 Subject: [PATCH 15/16] [RUM-16084] Merge remote config classes, refactor URL construction, move lifecycle wiring into DatadogCore --- Datadog/Datadog.xcodeproj/project.pbxproj | 16 +- DatadogCore/Sources/Core/DatadogCore.swift | 4 +- .../Sources/Core/RemoteConfiguration.swift | 40 ---- .../Core/RemoteConfigurationCache.swift | 73 ------ .../Core/RemoteConfigurationFetcher.swift | 91 -------- .../RemoteConfigurationSynchronizer.swift | 212 ++++++++++++++++++ DatadogCore/Sources/Datadog.swift | 13 +- .../RemoteConfigurationFetcherTests.swift | 4 +- .../Core/RemoteConfigurationTests.swift | 88 +++++--- DatadogCore/Tests/Datadog/DatadogTests.swift | 4 +- .../Sources/Context/DatadogSite.swift | 23 +- .../Tests/Context/DatadogSiteTests.swift | 72 ++---- 12 files changed, 305 insertions(+), 335 deletions(-) delete mode 100644 DatadogCore/Sources/Core/RemoteConfiguration.swift delete mode 100644 DatadogCore/Sources/Core/RemoteConfigurationCache.swift delete mode 100644 DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift create mode 100644 DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index a656d5d94f..42b1002e6f 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -19,11 +19,9 @@ 09A936A12F0EB989000B6379 /* SamplingPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */; }; 09A936A22F0EB989000B6379 /* SpanContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369C2F0EB989000B6379 /* SpanContext.swift */; }; 1019DFB52FA38A54006599B4 /* RemoteConfigurationCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */; }; - 1019DFB72FA38AC9006599B4 /* RemoteConfigurationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */; }; 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */; }; - 1019DFBB2FA3910A006599B4 /* RemoteConfigurationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */; }; 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */; }; - 10E9062A2FBDB81A002D5F45 /* RemoteConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */; }; + 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */; }; 10E9062C2FBDBA4B002D5F45 /* RemoteConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */; }; 11030D5F2D959EAD00732D5F /* DatadogLogs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D207317C29A5226A00ECBF94 /* DatadogLogs.framework */; }; 11030D6D2D95A48B00732D5F /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; }; @@ -1778,11 +1776,9 @@ 09A9369C2F0EB989000B6379 /* SpanContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpanContext.swift; sourceTree = ""; }; 0D2A20EFB1D82D0B8AC781F9 /* HeatmapMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMocks.swift; sourceTree = ""; }; 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationCacheTests.swift; sourceTree = ""; }; - 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationCache.swift; sourceTree = ""; }; 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcherTests.swift; sourceTree = ""; }; - 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcher.swift; sourceTree = ""; }; 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogSiteTests.swift; sourceTree = ""; }; - 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfiguration.swift; sourceTree = ""; }; + 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationSynchronizer.swift; sourceTree = ""; }; 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationTests.swift; sourceTree = ""; }; 11014EACF1FB9927DAD57822 /* EvaluationMocks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvaluationMocks.swift; sourceTree = ""; }; 11030D752D96EC5300732D5F /* ViewHitchesMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHitchesMetric.swift; sourceTree = ""; }; @@ -4470,9 +4466,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( - 10E906292FBDB818002D5F45 /* RemoteConfiguration.swift */, - 1019DFBA2FA39108006599B4 /* RemoteConfigurationFetcher.swift */, - 1019DFB62FA38AC3006599B4 /* RemoteConfigurationCache.swift */, + 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -7902,14 +7896,12 @@ D2A7840F29A53B2F003B03BB /* Directory.swift in Sources */, 61D3E0DB277B23F1008BE766 /* KronosNSTimer+ClosureKit.swift in Sources */, D20605A92874C1CD0047275C /* NetworkConnectionInfoPublisher.swift in Sources */, - 1019DFB72FA38AC9006599B4 /* RemoteConfigurationCache.swift in Sources */, 614396722A67D74F00197326 /* BatchMetrics.swift in Sources */, D20605A6287476230047275C /* ServerOffsetPublisher.swift in Sources */, 613E792F2577B0F900DFCC17 /* Reader.swift in Sources */, A731B8042EE1DD0D003D1E4F /* CrossPlatformExtension.swift in Sources */, 61D3E0D3277B23F1008BE766 /* KronosDNSResolver.swift in Sources */, D2DC4BF627F484AA00E4FB96 /* DataEncryption.swift in Sources */, - 1019DFBB2FA3910A006599B4 /* RemoteConfigurationFetcher.swift in Sources */, 11EA5C2E2DC3CCA500E8DFA2 /* DatadogConfiguration.swift in Sources */, D2FB1254292E0E96005B13F8 /* TrackingConsentPublisher.swift in Sources */, 61D3E0D6277B23F1008BE766 /* KronosClock.swift in Sources */, @@ -7925,7 +7917,7 @@ 617699182A860D9D0030022B /* HTTPClient.swift in Sources */, D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */, 61133BD42423979B00786299 /* FileReader.swift in Sources */, - 10E9062A2FBDB81A002D5F45 /* RemoteConfiguration.swift in Sources */, + 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */, D29294E0291D5ED100F8EFF9 /* ApplicationVersionPublisher.swift in Sources */, 61D3E0D9277B23F1008BE766 /* KronosNTPProtocol.swift in Sources */, 61D3E0DA277B23F1008BE766 /* KronosTimeFreeze.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 825ce9f0a8..4a1b764a82 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -57,7 +57,7 @@ internal final class DatadogCore { /// Owns the remote configuration cache and fetch lifecycle. /// `nil` when `remoteConfigurationID` was not set at init. - internal let remoteConfiguration: RemoteConfiguration? + internal let synchronizer: RemoteConfigurationSynchronizer? /// Registry for Features. @ReadWriteLock @@ -120,7 +120,7 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - self.remoteConfiguration = remoteConfigurationID.map { RemoteConfiguration(id: $0, directory: directory.coreDirectory) } + self.synchronizer = remoteConfigurationID.map { RemoteConfigurationSynchronizer(id: $0, directory: directory.coreDirectory) } // connect the core to the message bus. // the bus will keep a weak ref to the core. bus.connect(core: self) diff --git a/DatadogCore/Sources/Core/RemoteConfiguration.swift b/DatadogCore/Sources/Core/RemoteConfiguration.swift deleted file mode 100644 index d2186ac8f7..0000000000 --- a/DatadogCore/Sources/Core/RemoteConfiguration.swift +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogInternal - -/// Owns the remote configuration cache and fetch lifecycle. -/// -/// Created by `DatadogCore` when a `remoteConfigurationID` is provided. -/// Call `start(from:connectionProxyDictionary:telemetry:)` once the core is -/// registered to fire the initial CDN fetch. -/// -/// TODO: Also trigger `start()` on every app foreground transition so remote config -/// is refreshed while the app is in use, not only at SDK init (RFC §Caching Strategy). -internal final class RemoteConfiguration { - let cache: RemoteConfigurationCache - - init(id: String, directory: Directory) { - self.cache = RemoteConfigurationCache(id: id, directory: directory) - } - - /// Reports any load error from the previous session and fires an async CDN fetch. - /// - /// - Parameter session: Injected only in tests; pass `nil` in production. - func start(from endpoint: URL, connectionProxyDictionary: [AnyHashable: Any]?, telemetry: Telemetry, session: URLSession? = nil) { - if let error = cache.loadError { - telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: error) - } - let fetcher: RemoteConfigurationFetcher - if let session = session { - fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: session) - } else { - fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: connectionProxyDictionary, telemetry: telemetry) - } - fetcher.fetch(from: endpoint) - } -} diff --git a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift b/DatadogCore/Sources/Core/RemoteConfigurationCache.swift deleted file mode 100644 index d569b4550f..0000000000 --- a/DatadogCore/Sources/Core/RemoteConfigurationCache.swift +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogInternal - -/// Manages the on-disk cache of the remote configuration JSON document. -/// -/// The cache is a single file named after the remote configuration ID, stored at -/// the root of the SDK's private core directory: -/// -/// /Library/Caches/com.datadoghq/v2//.json -/// -/// The file contains raw JSON bytes exactly as received from the CDN. -/// Parsing and applying those values is handled separately. -internal final class RemoteConfigurationCache { - private let fileURL: URL - - /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. - /// `nil` when no cache exists yet (first launch, or no file on disk). - /// Consumed by the config-application layer once parsing and applying remote values is implemented. - private(set) var data: Data? - - /// Error encountered when reading the cache file at init, if any. - /// `nil` when the file was absent (expected on first launch) or read successfully. - private(set) var loadError: Error? - - init(id: String, directory: Directory) { - self.fileURL = directory.url.appendingPathComponent("\(id).json") - // Synchronous read on the caller's thread (main thread during SDK init). - // Acceptable because the file is small (a single JSON document) and only - // present after a previous successful fetch — absent on first launch. - let (data, error) = Self.readFromDisk(at: fileURL) - self.data = data - self.loadError = error - } - - // MARK: - Private - - private static func readFromDisk(at url: URL) -> (Data?, Error?) { - guard FileManager.default.fileExists(atPath: url.path) else { - return (nil, nil) - } - do { - return (try Data(contentsOf: url), nil) - } catch { - return (nil, error) - } - } - - // MARK: - Internal - - /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. - /// Called only on a successful CDN response — never on failure. - /// In-memory `data` is only updated when the disk write succeeds, keeping - /// the two in sync. - /// - Returns: `nil` on success, or the underlying write error on failure. - @discardableResult - func save(_ data: Data) -> Error? { - do { - try data.write(to: fileURL, options: .atomic) - self.data = data - return nil - } catch { - // self.data is intentionally NOT updated so in-memory state stays - // consistent with what is actually on disk. - return error - } - } -} diff --git a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift b/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift deleted file mode 100644 index 63b2885014..0000000000 --- a/DatadogCore/Sources/Core/RemoteConfigurationFetcher.swift +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogInternal - -/// Fetches the remote configuration JSON document from the CDN and delegates -/// storage to `RemoteConfigurationCache`. -/// -/// Rules: -/// - Fetch is always asynchronous — never blocks the caller. -/// - On success (2xx, non-empty body): calls `cache.save(_:)`. -/// - On any failure: reports a telemetry error and leaves the existing cache untouched. -internal final class RemoteConfigurationFetcher { - private let cache: RemoteConfigurationCache - private let telemetry: Telemetry - private let session: URLSession - - init( - cache: RemoteConfigurationCache, - telemetry: Telemetry, - session: URLSession - ) { - self.cache = cache - self.telemetry = telemetry - self.session = session - } - - convenience init( - cache: RemoteConfigurationCache, - connectionProxyDictionary: [AnyHashable: Any]?, - telemetry: Telemetry - ) { - let config = URLSessionConfiguration.ephemeral - config.urlCache = nil - config.connectionProxyDictionary = connectionProxyDictionary - self.init(cache: cache, telemetry: telemetry, session: URLSession(configuration: config)) - } - - /// Fires a background GET request to `endpoint`. - /// - /// - Parameter endpoint: The CDN URL to fetch from. - /// - Parameter didComplete: Called when the fetch (and any write) is done. - /// Pass `nil` in production; inject a closure in tests to await completion. - func fetch(from endpoint: URL, didComplete: (() -> Void)? = nil) { - // TODO RUM-16386: Add ETag-based conditional requests (If-None-Match / 304) and - // TTL-based revalidation (skip fetch if cached config is < 5 min old). - let cache = self.cache - let telemetry = self.telemetry - let task = session.dataTask(with: endpoint) { data, response, error in - defer { didComplete?() } - - // 1. Network error - if let error = error { - telemetry.error("[RemoteConfig] Network error", error: error) - return - } - - // 2. Non-2xx HTTP status - guard let http = response as? HTTPURLResponse, - (200..<300).contains(http.statusCode) else { - let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - // Use a fixed message so all HTTP errors bucket together in telemetry; - // the status code lives in the error object, not the message string. - telemetry.error("[RemoteConfig] Non-2xx response", error: NSError( - domain: "RemoteConfiguration", - code: code, - userInfo: [NSLocalizedDescriptionKey: "HTTP \(code)"] - )) - return - } - - // 3. Empty body - guard let data = data, !data.isEmpty else { - telemetry.error("[RemoteConfig] Empty response body") - return - } - - // TODO RUM-16387: Validate the schema before saving - - // All checks passed — persist to disk - if let error = cache.save(data) { - telemetry.error("[RemoteConfig] Failed to write remote configuration to disk", error: error) - } - } - task.resume() - } -} diff --git a/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift b/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift new file mode 100644 index 0000000000..95b508b931 --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift @@ -0,0 +1,212 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +// MARK: - RemoteConfigurationSynchronizer + +/// Owns the remote configuration cache and fetch lifecycle. +/// +/// Created and started by `DatadogCore` during initialization when a `remoteConfigurationID` +/// is provided. `start(from:connectionProxyDictionary:telemetry:)` fires the initial CDN fetch. +/// +/// TODO: Also trigger `start()` on every app foreground transition so remote config +/// is refreshed while the app is in use, not only at SDK init (RFC §Caching Strategy). +internal final class RemoteConfigurationSynchronizer { + let cache: RemoteConfigurationCache + + init(id: String, directory: Directory) { + self.cache = RemoteConfigurationCache(id: id, directory: directory) + } + + /// Constructs the CDN URL for fetching remote configuration. + /// + /// - Parameters: + /// - id: The remote configuration ID from `Datadog.Configuration.remoteConfigurationID`. + /// - host: The CDN hostname from `DatadogSite.remoteConfigurationHost`. + /// - Returns: URL to GET the config JSON, or `nil` if `id` cannot be percent-encoded. + static func endpoint(for id: String, host: String) -> URL? { + // `.urlPathAllowed` leaves `/`, `?`, and `#` unencoded (they are legal in a URL path). + // Subtract them so an ID containing those characters doesn't produce extra path + // segments, a query string, or a fragment. + let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#")) + guard let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { + return nil + } + return URL(string: "https://\(host)/v1/\(encoded).json") + } + + /// Reports any load error from the previous session and fires an async CDN fetch. + /// + /// - Parameters: + /// - session: Injected only in tests; pass `nil` in production. + /// - didComplete: Called when the fetch (and any cache write) is done. Injected only in tests; pass `nil` in production. + func start(from endpoint: URL, connectionProxyDictionary: [AnyHashable: Any]?, telemetry: Telemetry, session: URLSession? = nil, didComplete: (() -> Void)? = nil) { + if let error = cache.loadError { + telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: error) + } + let fetcher: RemoteConfigurationFetcher + if let session = session { + fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: session) + } else { + fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: connectionProxyDictionary, telemetry: telemetry) + } + fetcher.fetch(from: endpoint, didComplete: didComplete) + } +} + +// MARK: - RemoteConfigurationCache + +/// Manages the on-disk cache of the remote configuration JSON document. +/// +/// The cache is a single file named after the remote configuration ID, stored at +/// the root of the SDK's private core directory: +/// +/// /Library/Caches/com.datadoghq/v2//.json +/// +/// The file contains raw JSON bytes exactly as received from the CDN. +/// Parsing and applying those values is handled separately. +internal final class RemoteConfigurationCache { + private let fileURL: URL + + /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. + /// `nil` when no cache exists yet (first launch, or no file on disk). + /// Consumed by the config-application layer once parsing and applying remote values is implemented. + private(set) var data: Data? + + /// Error encountered when reading the cache file at init, if any. + /// `nil` when the file was absent (expected on first launch) or read successfully. + private(set) var loadError: Error? + + init(id: String, directory: Directory) { + self.fileURL = directory.url.appendingPathComponent("\(id).json") + // Synchronous read on the caller's thread (main thread during SDK init). + // Acceptable because the file is small (a single JSON document) and only + // present after a previous successful fetch — absent on first launch. + let (data, error) = Self.readFromDisk(at: fileURL) + self.data = data + self.loadError = error + } + + // MARK: - Private + + private static func readFromDisk(at url: URL) -> (Data?, Error?) { + guard FileManager.default.fileExists(atPath: url.path) else { + return (nil, nil) + } + do { + return (try Data(contentsOf: url), nil) + } catch { + return (nil, error) + } + } + + // MARK: - Internal + + /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. + /// Called only on a successful CDN response — never on failure. + /// In-memory `data` is only updated when the disk write succeeds, keeping + /// the two in sync. + /// - Returns: `nil` on success, or the underlying write error on failure. + @discardableResult + func save(_ data: Data) -> Error? { + do { + try data.write(to: fileURL, options: .atomic) + self.data = data + return nil + } catch { + // self.data is intentionally NOT updated so in-memory state stays + // consistent with what is actually on disk. + return error + } + } +} + +// MARK: - RemoteConfigurationFetcher + +/// Fetches the remote configuration JSON document from the CDN and delegates +/// storage to `RemoteConfigurationCache`. +/// +/// Rules: +/// - Fetch is always asynchronous — never blocks the caller. +/// - On success (2xx, non-empty body): calls `cache.save(_:)`. +/// - On any failure: reports a telemetry error and leaves the existing cache untouched. +internal final class RemoteConfigurationFetcher { + private let cache: RemoteConfigurationCache + private let telemetry: Telemetry + private let session: URLSession + + init( + cache: RemoteConfigurationCache, + telemetry: Telemetry, + session: URLSession + ) { + self.cache = cache + self.telemetry = telemetry + self.session = session + } + + convenience init( + cache: RemoteConfigurationCache, + connectionProxyDictionary: [AnyHashable: Any]?, + telemetry: Telemetry + ) { + let config = URLSessionConfiguration.ephemeral + config.urlCache = nil + config.connectionProxyDictionary = connectionProxyDictionary + self.init(cache: cache, telemetry: telemetry, session: URLSession(configuration: config)) + } + + /// Fires a background GET request to `endpoint`. + /// + /// - Parameter endpoint: The CDN URL to fetch from. + /// - Parameter didComplete: Called when the fetch (and any write) is done. + /// Pass `nil` in production; inject a closure in tests to await completion. + func fetch(from endpoint: URL, didComplete: (() -> Void)? = nil) { + // TODO RUM-16386: Add ETag-based conditional requests (If-None-Match / 304) and + // TTL-based revalidation (skip fetch if cached config is < 5 min old). + let cache = self.cache + let telemetry = self.telemetry + let task = session.dataTask(with: endpoint) { data, response, error in + defer { didComplete?() } + + // 1. Network error + if let error = error { + telemetry.error("[RemoteConfig] Network error", error: error) + return + } + + // 2. Non-2xx HTTP status + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + // Use a fixed message so all HTTP errors bucket together in telemetry; + // the status code lives in the error object, not the message string. + telemetry.error("[RemoteConfig] Non-2xx response", error: NSError( + domain: "RemoteConfiguration", + code: code, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(code)"] + )) + return + } + + // 3. Empty body + guard let data = data, !data.isEmpty else { + telemetry.error("[RemoteConfig] Empty response body") + return + } + + // TODO RUM-16387: Validate the schema before saving + + // All checks passed — persist to disk + if let error = cache.save(data) { + telemetry.error("[RemoteConfig] Failed to write remote configuration to disk", error: error) + } + } + task.resume() + } +} diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 9fce8a4921..f7dfe5e67e 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -299,12 +299,6 @@ public enum Datadog { CoreRegistry.register(core, named: instanceName) - if let id = configuration.remoteConfigurationID, - let endpoint = configuration.site.remoteConfigurationURL(for: id) { - // remoteConfiguration is always non-nil here — DatadogCore creates it for any non-nil remoteConfigurationID. - core.remoteConfiguration?.start(from: endpoint, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: core.telemetry) - } - deleteV1Folders(in: core) DD.logger = InternalLogger( @@ -471,6 +465,13 @@ extension DatadogCore { remoteConfigurationID: configuration.remoteConfigurationID ) + if let synchronizer = self.synchronizer, + let id = configuration.remoteConfigurationID, + let host = configuration.site.remoteConfigurationHost, + let endpoint = RemoteConfigurationSynchronizer.endpoint(for: id, host: host) { + synchronizer.start(from: endpoint, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: telemetry) + } + telemetry.configuration( backgroundTasksEnabled: configuration.backgroundTasksEnabled, batchProcessingLevel: Int64(exactly: configuration.batchProcessingLevel.maxBatchesPerUpload), diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift index e34faed8d9..922d719e26 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift @@ -12,7 +12,7 @@ import DatadogInternal // MARK: MockURLProtocol -private class MockURLProtocol: URLProtocol { +class MockURLProtocol: URLProtocol { /// Set this before each test to control what the mock returns. static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? @@ -39,7 +39,7 @@ private class MockURLProtocol: URLProtocol { // MARK: Helper -private func mockSession() -> URLSession { +func mockSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] return URLSession(configuration: config) diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift index 273a76d54a..69ea7b45fd 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift @@ -8,38 +8,6 @@ import XCTest import TestUtilities @testable import DatadogCore -// MARK: - MockURLProtocol - -private class MockURLProtocol: URLProtocol { - static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let handler = MockURLProtocol.requestHandler else { - client?.urlProtocolDidFinishLoading(self) - return - } - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - if let data = data { client?.urlProtocol(self, didLoad: data) } - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} - -private func mockSession() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - return URLSession(configuration: config) -} - // MARK: - Tests class RemoteConfigurationTests: XCTestCase { @@ -61,10 +29,37 @@ class RemoteConfigurationTests: XCTestCase { super.tearDown() } + // MARK: endpoint(for:host:) + + func testEndpointBuildsCorrectURL() { + let url = RemoteConfigurationSynchronizer.endpoint(for: "abc-123", host: "sdk-configuration.browser-intake-datadoghq.com") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/abc-123.json") + } + + func testEndpointPercentEncodesSpacesInID() { + let url = RemoteConfigurationSynchronizer.endpoint(for: "hello world", host: "sdk-configuration.browser-intake-datadoghq.com") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/hello%20world.json") + } + + func testEndpointEncodesSlashSoItDoesNotProduceExtraPathSegments() { + let url = RemoteConfigurationSynchronizer.endpoint(for: "a/b", host: "sdk-configuration.browser-intake-datadoghq.com") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/a%2Fb.json") + } + + func testEndpointEncodesQuestionMarkSoItDoesNotProduceQueryString() { + let url = RemoteConfigurationSynchronizer.endpoint(for: "id?query", host: "sdk-configuration.browser-intake-datadoghq.com") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%3Fquery.json") + } + + func testEndpointEncodesHashSoItDoesNotProduceFragment() { + let url = RemoteConfigurationSynchronizer.endpoint(for: "id#section", host: "sdk-configuration.browser-intake-datadoghq.com") + XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%23section.json") + } + // MARK: Init func testInitCreatesCacheForGivenID() { - let rc = RemoteConfiguration(id: "abc", directory: coreDir.coreDirectory) + let rc = RemoteConfigurationSynchronizer(id: "abc", directory: coreDir.coreDirectory) // Cache is created — no data yet on first launch, no error either. XCTAssertNil(rc.cache.data) XCTAssertNil(rc.cache.loadError) @@ -80,7 +75,7 @@ class RemoteConfigurationTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fileURL.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) } - let rc = RemoteConfiguration(id: id, directory: coreDir.coreDirectory) + let rc = RemoteConfigurationSynchronizer(id: id, directory: coreDir.coreDirectory) XCTAssertNotNil(rc.cache.loadError, "Precondition: cache must have a loadError") // When @@ -105,7 +100,7 @@ class RemoteConfigurationTests: XCTestCase { MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) } - let rc = RemoteConfiguration(id: "clean-id", directory: coreDir.coreDirectory) + let rc = RemoteConfigurationSynchronizer(id: "clean-id", directory: coreDir.coreDirectory) XCTAssertNil(rc.cache.loadError, "Precondition: no load error expected") // When @@ -123,4 +118,27 @@ class RemoteConfigurationTests: XCTestCase { "start() must not report a telemetry error when there is no load error" ) } + + func testStartFetchesAndPopulatesCache() { + // Given + let payload = Data("{\"session_sample_rate\":50}".utf8) + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) + } + let rc = RemoteConfigurationSynchronizer(id: "fetch-id", directory: coreDir.coreDirectory) + let expectation = expectation(description: "fetch completes") + + // When + rc.start( + from: URL(string: "https://example.com")!, + connectionProxyDictionary: nil, + telemetry: TelemetryMock(), + session: session, + didComplete: { expectation.fulfill() } + ) + wait(for: [expectation], timeout: 2) + + // Then — the fetched payload must be written to cache + XCTAssertEqual(rc.cache.data, payload, "cache.data must be populated after a successful fetch") + } } diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index 0876420589..a3fe9d7c4e 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -532,7 +532,7 @@ class DatadogTests: XCTestCase { // Then let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) - XCTAssertNil(core.remoteConfiguration) + XCTAssertNil(core.synchronizer) } func testGivenRemoteConfigurationID_remoteConfigurationIsCreated() throws { @@ -546,7 +546,7 @@ class DatadogTests: XCTestCase { // Then let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) - XCTAssertNotNil(core.remoteConfiguration) + XCTAssertNotNil(core.synchronizer) } func testCustomSDKInstance() throws { diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index efdf90a2bd..e1921a4c53 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -49,23 +49,12 @@ extension DatadogSite { } } - /// Constructs the CDN URL for fetching the remote configuration document. - /// - Parameter id: The value of `Datadog.Configuration.remoteConfigurationID`. - /// - Returns: URL to GET the config JSON from, or `nil` if `id` cannot be percent-encoded - /// or if the resulting URL string is malformed. + /// The CDN hostname used for fetching remote configuration documents. + /// Format: `sdk-configuration.` + /// + /// The full URL (with path and ID) is constructed by `RemoteConfigurationSynchronizer`. @_spi(Internal) - public func remoteConfigurationURL(for id: String) -> URL? { - // Format: https://sdk-configuration.browser-intake-{site}/v1/{id}.json - // `.urlPathAllowed` leaves `/`, `?`, and `#` unencoded (they are legal in a URL). - // Subtract them so an ID containing those characters doesn't produce extra path - // segments, a query string, or a fragment. - let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#")) - guard - let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed), - let host = endpoint.host - else { - return nil - } - return URL(string: "https://sdk-configuration.\(host)/v1/\(encoded).json") + public var remoteConfigurationHost: String? { + endpoint.host.map { "sdk-configuration.\($0)" } } } diff --git a/DatadogInternal/Tests/Context/DatadogSiteTests.swift b/DatadogInternal/Tests/Context/DatadogSiteTests.swift index 21e071951d..5ef513a50b 100644 --- a/DatadogInternal/Tests/Context/DatadogSiteTests.swift +++ b/DatadogInternal/Tests/Context/DatadogSiteTests.swift @@ -9,75 +9,37 @@ import XCTest import DatadogInternal class DatadogSiteTests: XCTestCase { - // MARK: - remoteConfigurationURL per site + // MARK: - remoteConfigurationHost per site - func testUS1RemoteConfigurationURL() { - let url = DatadogSite.us1.remoteConfigurationURL(for: "abc-123") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/abc-123.json") + func testUS1RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.us1.remoteConfigurationHost, "sdk-configuration.browser-intake-datadoghq.com") } - func testUS3RemoteConfigurationURL() { - let expected = "https://sdk-configuration.browser-intake-us3-datadoghq.com/v1/abc-123.json" - XCTAssertEqual(DatadogSite.us3.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + func testUS3RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.us3.remoteConfigurationHost, "sdk-configuration.browser-intake-us3-datadoghq.com") } - func testUS5RemoteConfigurationURL() { - let expected = "https://sdk-configuration.browser-intake-us5-datadoghq.com/v1/abc-123.json" - XCTAssertEqual(DatadogSite.us5.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + func testUS5RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.us5.remoteConfigurationHost, "sdk-configuration.browser-intake-us5-datadoghq.com") } - func testEU1RemoteConfigurationURL() { - let url = DatadogSite.eu1.remoteConfigurationURL(for: "abc-123") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.eu/v1/abc-123.json") + func testEU1RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.eu1.remoteConfigurationHost, "sdk-configuration.browser-intake-datadoghq.eu") } - func testAP1RemoteConfigurationURL() { - let expected = "https://sdk-configuration.browser-intake-ap1-datadoghq.com/v1/abc-123.json" - XCTAssertEqual(DatadogSite.ap1.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + func testAP1RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.ap1.remoteConfigurationHost, "sdk-configuration.browser-intake-ap1-datadoghq.com") } - func testAP2RemoteConfigurationURL() { - let expected = "https://sdk-configuration.browser-intake-ap2-datadoghq.com/v1/abc-123.json" - XCTAssertEqual(DatadogSite.ap2.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) + func testAP2RemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.ap2.remoteConfigurationHost, "sdk-configuration.browser-intake-ap2-datadoghq.com") } - func testUS1FedRemoteConfigurationURL() { - let url = DatadogSite.us1_fed.remoteConfigurationURL(for: "abc-123") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-ddog-gov.com/v1/abc-123.json") + func testUS1FedRemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.us1_fed.remoteConfigurationHost, "sdk-configuration.browser-intake-ddog-gov.com") } - func testUS2FedRemoteConfigurationURL() { - let expected = "https://sdk-configuration.browser-intake-us2-ddog-gov.com/v1/abc-123.json" - XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationURL(for: "abc-123")?.absoluteString, expected) - } - - // MARK: - ID encoding - - func testIDWithSpacesIsPercentEncoded() { - let url = DatadogSite.us1.remoteConfigurationURL(for: "hello world") - let expected = "https://sdk-configuration.browser-intake-datadoghq.com/v1/hello%20world.json" - XCTAssertNotNil(url, "URL must be constructed even when id contains spaces") - XCTAssertEqual(url?.absoluteString, expected) - } - - func testIDWithSlashDoesNotProduceExtraPathSegments() { - // A slash in the ID must be encoded as %2F, not left as a literal path separator. - // Without this, "a/b" would produce …/v1/a/b.json (wrong path) instead of …/v1/a%2Fb.json. - let url = DatadogSite.us1.remoteConfigurationURL(for: "a/b") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/a%2Fb.json") - } - - func testIDWithQuestionMarkDoesNotProduceQueryString() { - // A `?` in the ID must be encoded as %3F so it doesn't start a query string. - // Without this, "id?query" would produce …/v1/id?query.json (malformed URL). - let url = DatadogSite.us1.remoteConfigurationURL(for: "id?query") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%3Fquery.json") - } - - func testIDWithHashDoesNotProduceFragment() { - // A `#` in the ID must be encoded as %23 so it doesn't start a URL fragment. - // Without this, "id#section" would produce …/v1/id#section.json (truncated path). - let url = DatadogSite.us1.remoteConfigurationURL(for: "id#section") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%23section.json") + func testUS2FedRemoteConfigurationHost() { + XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationHost, "sdk-configuration.browser-intake-us2-ddog-gov.com") } } From b6736f5b3c2bcdcb789c33ec385e02194e80eb1a Mon Sep 17 00:00:00 2001 From: Sara Susano Date: Mon, 25 May 2026 14:40:04 +0200 Subject: [PATCH 16/16] [RUM-16084] Simplify to single class, reuse HTTPClient and Directory, refactor DatadogSite --- Datadog/Datadog.xcodeproj/project.pbxproj | 8 - DatadogCore/Sources/Core/DatadogCore.swift | 10 +- .../RemoteConfigurationSynchronizer.swift | 242 ++++---------- .../Sources/Core/Storage/Files/File.swift | 4 + .../Sources/Core/Upload/HTTPClient.swift | 11 + .../Core/Upload/URLSessionClient.swift | 15 + DatadogCore/Sources/Datadog.swift | 11 +- .../Core/RemoteConfigurationCacheTests.swift | 95 ------ .../RemoteConfigurationFetcherTests.swift | 243 -------------- .../Core/RemoteConfigurationTests.swift | 310 +++++++++++++----- .../Sources/Context/DatadogSite.swift | 38 ++- .../Tests/Context/DatadogSiteTests.swift | 57 +++- 12 files changed, 397 insertions(+), 647 deletions(-) delete mode 100644 DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift delete mode 100644 DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 42b1002e6f..aacb06497a 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -18,8 +18,6 @@ 09A936A02F0EB989000B6379 /* SamplingMechanismType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369A2F0EB989000B6379 /* SamplingMechanismType.swift */; }; 09A936A12F0EB989000B6379 /* SamplingPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */; }; 09A936A22F0EB989000B6379 /* SpanContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369C2F0EB989000B6379 /* SpanContext.swift */; }; - 1019DFB52FA38A54006599B4 /* RemoteConfigurationCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */; }; - 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */; }; 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */; }; 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */; }; 10E9062C2FBDBA4B002D5F45 /* RemoteConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */; }; @@ -1775,8 +1773,6 @@ 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplingPriority.swift; sourceTree = ""; }; 09A9369C2F0EB989000B6379 /* SpanContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpanContext.swift; sourceTree = ""; }; 0D2A20EFB1D82D0B8AC781F9 /* HeatmapMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMocks.swift; sourceTree = ""; }; - 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationCacheTests.swift; sourceTree = ""; }; - 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationFetcherTests.swift; sourceTree = ""; }; 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogSiteTests.swift; sourceTree = ""; }; 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationSynchronizer.swift; sourceTree = ""; }; 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationTests.swift; sourceTree = ""; }; @@ -4594,8 +4590,6 @@ isa = PBXGroup; children = ( 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */, - 1019DFB82FA39077006599B4 /* RemoteConfigurationFetcherTests.swift */, - 1019DFB42FA38A4C006599B4 /* RemoteConfigurationCacheTests.swift */, 61DA8CAB2861C3720074A606 /* DirectoriesTests.swift */, 61EF78C0257F842000EDCCB3 /* FeatureTests.swift */, 61345612244756E300E7DA6B /* PerformancePresetTests.swift */, @@ -7966,7 +7960,6 @@ 61133C5A2423990D00786299 /* FileTests.swift in Sources */, 61DB33B225DEDFC200F7EA71 /* CustomObjcViewController.m in Sources */, D2EFA875286E011900F1FAA6 /* DatadogContextProviderTests.swift in Sources */, - 1019DFB92FA390A9006599B4 /* RemoteConfigurationFetcherTests.swift in Sources */, 61363D9F24D99BAA0084CD6F /* DDErrorTests.swift in Sources */, A727C4BB2BADB3AB00707DFD /* DDSessionReplay+apiTests.m in Sources */, 110311762EF96F6700750DD4 /* DDLogs+apiTests.m in Sources */, @@ -8032,7 +8025,6 @@ 1132B34D2D9E9A30002384F2 /* RUMViewHitchesMetricIntegrationTests.swift in Sources */, D2A1EE442886B8B400D28DFB /* UserInfoPublisherTests.swift in Sources */, D29A9FD029DDC58E005C54A4 /* RUMFeatureTests.swift in Sources */, - 1019DFB52FA38A54006599B4 /* RemoteConfigurationCacheTests.swift in Sources */, 61F930C82BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */, D28F836529C9E69E00EF8EA2 /* DatadogTraceFeatureTests.swift in Sources */, 61133C4B2423990D00786299 /* DDLogsTests.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 4a1b764a82..730da4db4b 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -56,7 +56,7 @@ internal final class DatadogCore { let bus = MessageBus() /// Owns the remote configuration cache and fetch lifecycle. - /// `nil` when `remoteConfigurationID` was not set at init. + /// `nil` when `remoteConfiguration` was not set at init. internal let synchronizer: RemoteConfigurationSynchronizer? /// Registry for Features. @@ -90,7 +90,7 @@ internal final class DatadogCore { /// - encryption: The on-disk data encryption. /// - contextProvider: The core context provider. /// - applicationVersion: The application version. - /// - remoteConfigurationID: The remote configuration ID; `nil` when not configured. + /// - remoteConfiguration: The remote configuration ID and site; `nil` when not configured. init( directory: CoreDirectory, dateProvider: DateProvider, @@ -103,7 +103,7 @@ internal final class DatadogCore { maxBatchesPerUpload: Int, backgroundTasksEnabled: Bool, isRunFromExtension: Bool = false, - remoteConfigurationID: String? = nil + remoteConfiguration: (id: String, site: DatadogSite)? = nil ) { self.directory = directory self.dateProvider = dateProvider @@ -120,7 +120,9 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - self.synchronizer = remoteConfigurationID.map { RemoteConfigurationSynchronizer(id: $0, directory: directory.coreDirectory) } + self.synchronizer = remoteConfiguration.map { + RemoteConfigurationSynchronizer(id: $0.id, site: $0.site, directory: directory.coreDirectory, httpClient: httpClient) + } // connect the core to the message bus. // the bus will keep a weak ref to the core. bus.connect(core: self) diff --git a/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift b/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift index 95b508b931..b4b51a3405 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift @@ -7,206 +7,96 @@ import Foundation import DatadogInternal -// MARK: - RemoteConfigurationSynchronizer - /// Owns the remote configuration cache and fetch lifecycle. /// -/// Created and started by `DatadogCore` during initialization when a `remoteConfigurationID` -/// is provided. `start(from:connectionProxyDictionary:telemetry:)` fires the initial CDN fetch. +/// Created by `DatadogCore` during initialization when a `remoteConfigurationID` is provided. +/// Call `sync(_:)` to fire a CDN fetch and update the cache. /// -/// TODO: Also trigger `start()` on every app foreground transition so remote config +/// TODO: Also trigger `sync()` on every app foreground transition so remote config /// is refreshed while the app is in use, not only at SDK init (RFC §Caching Strategy). internal final class RemoteConfigurationSynchronizer { - let cache: RemoteConfigurationCache - - init(id: String, directory: Directory) { - self.cache = RemoteConfigurationCache(id: id, directory: directory) - } - - /// Constructs the CDN URL for fetching remote configuration. - /// - /// - Parameters: - /// - id: The remote configuration ID from `Datadog.Configuration.remoteConfigurationID`. - /// - host: The CDN hostname from `DatadogSite.remoteConfigurationHost`. - /// - Returns: URL to GET the config JSON, or `nil` if `id` cannot be percent-encoded. - static func endpoint(for id: String, host: String) -> URL? { - // `.urlPathAllowed` leaves `/`, `?`, and `#` unencoded (they are legal in a URL path). - // Subtract them so an ID containing those characters doesn't produce extra path - // segments, a query string, or a fragment. - let pathSegmentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#")) - guard let encoded = id.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { - return nil - } - return URL(string: "https://\(host)/v1/\(encoded).json") - } - - /// Reports any load error from the previous session and fires an async CDN fetch. - /// - /// - Parameters: - /// - session: Injected only in tests; pass `nil` in production. - /// - didComplete: Called when the fetch (and any cache write) is done. Injected only in tests; pass `nil` in production. - func start(from endpoint: URL, connectionProxyDictionary: [AnyHashable: Any]?, telemetry: Telemetry, session: URLSession? = nil, didComplete: (() -> Void)? = nil) { - if let error = cache.loadError { - telemetry.error("[RemoteConfig] Failed to load cached configuration from disk", error: error) - } - let fetcher: RemoteConfigurationFetcher - if let session = session { - fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: session) - } else { - fetcher = RemoteConfigurationFetcher(cache: cache, connectionProxyDictionary: connectionProxyDictionary, telemetry: telemetry) - } - fetcher.fetch(from: endpoint, didComplete: didComplete) - } -} - -// MARK: - RemoteConfigurationCache - -/// Manages the on-disk cache of the remote configuration JSON document. -/// -/// The cache is a single file named after the remote configuration ID, stored at -/// the root of the SDK's private core directory: -/// -/// /Library/Caches/com.datadoghq/v2//.json -/// -/// The file contains raw JSON bytes exactly as received from the CDN. -/// Parsing and applying those values is handled separately. -internal final class RemoteConfigurationCache { - private let fileURL: URL - - /// Raw JSON bytes from the previous CDN fetch, read synchronously at init. - /// `nil` when no cache exists yet (first launch, or no file on disk). - /// Consumed by the config-application layer once parsing and applying remote values is implemented. - private(set) var data: Data? - - /// Error encountered when reading the cache file at init, if any. - /// `nil` when the file was absent (expected on first launch) or read successfully. - private(set) var loadError: Error? - - init(id: String, directory: Directory) { - self.fileURL = directory.url.appendingPathComponent("\(id).json") + let id: String + let site: DatadogSite + let directory: Directory + let httpClient: HTTPClient + + /// The result of the last cache read or CDN fetch. + /// `.success(data)` — data is available. + /// `.failure` — no cache yet, or a read/write error. + @ReadWriteLock + private(set) var cache: Result + + init(id: String, site: DatadogSite, directory: Directory, httpClient: HTTPClient) { + self.id = id + self.site = site + self.directory = directory + self.httpClient = httpClient // Synchronous read on the caller's thread (main thread during SDK init). // Acceptable because the file is small (a single JSON document) and only // present after a previous successful fetch — absent on first launch. - let (data, error) = Self.readFromDisk(at: fileURL) - self.data = data - self.loadError = error + self._cache = ReadWriteLock(wrappedValue: Self.readCache(id: id, from: directory)) } // MARK: - Private - private static func readFromDisk(at url: URL) -> (Data?, Error?) { - guard FileManager.default.fileExists(atPath: url.path) else { - return (nil, nil) - } + private static func readCache(id: String, from directory: Directory) -> Result { do { - return (try Data(contentsOf: url), nil) + return .success(try directory.file(named: "\(id).json").read()) } catch { - return (nil, error) + return .failure(error) } } // MARK: - Internal - /// Writes raw CDN response bytes to disk atomically and updates the in-memory copy. - /// Called only on a successful CDN response — never on failure. - /// In-memory `data` is only updated when the disk write succeeds, keeping - /// the two in sync. - /// - Returns: `nil` on success, or the underlying write error on failure. - @discardableResult - func save(_ data: Data) -> Error? { - do { - try data.write(to: fileURL, options: .atomic) - self.data = data - return nil - } catch { - // self.data is intentionally NOT updated so in-memory state stays - // consistent with what is actually on disk. - return error - } - } -} - -// MARK: - RemoteConfigurationFetcher - -/// Fetches the remote configuration JSON document from the CDN and delegates -/// storage to `RemoteConfigurationCache`. -/// -/// Rules: -/// - Fetch is always asynchronous — never blocks the caller. -/// - On success (2xx, non-empty body): calls `cache.save(_:)`. -/// - On any failure: reports a telemetry error and leaves the existing cache untouched. -internal final class RemoteConfigurationFetcher { - private let cache: RemoteConfigurationCache - private let telemetry: Telemetry - private let session: URLSession - - init( - cache: RemoteConfigurationCache, - telemetry: Telemetry, - session: URLSession - ) { - self.cache = cache - self.telemetry = telemetry - self.session = session - } - - convenience init( - cache: RemoteConfigurationCache, - connectionProxyDictionary: [AnyHashable: Any]?, - telemetry: Telemetry - ) { - let config = URLSessionConfiguration.ephemeral - config.urlCache = nil - config.connectionProxyDictionary = connectionProxyDictionary - self.init(cache: cache, telemetry: telemetry, session: URLSession(configuration: config)) - } - - /// Fires a background GET request to `endpoint`. + /// Fires an async CDN fetch and updates the cache on success. /// - /// - Parameter endpoint: The CDN URL to fetch from. - /// - Parameter didComplete: Called when the fetch (and any write) is done. - /// Pass `nil` in production; inject a closure in tests to await completion. - func fetch(from endpoint: URL, didComplete: (() -> Void)? = nil) { + /// - Parameter completionHandler: Called with the fetch result when the operation (and any cache write) is done. + func sync(_ completionHandler: @escaping (Result) -> Void) { // TODO RUM-16386: Add ETag-based conditional requests (If-None-Match / 304) and // TTL-based revalidation (skip fetch if cached config is < 5 min old). - let cache = self.cache - let telemetry = self.telemetry - let task = session.dataTask(with: endpoint) { data, response, error in - defer { didComplete?() } - - // 1. Network error - if let error = error { - telemetry.error("[RemoteConfig] Network error", error: error) - return - } - - // 2. Non-2xx HTTP status - guard let http = response as? HTTPURLResponse, - (200..<300).contains(http.statusCode) else { - let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - // Use a fixed message so all HTTP errors bucket together in telemetry; - // the status code lives in the error object, not the message string. - telemetry.error("[RemoteConfig] Non-2xx response", error: NSError( - domain: "RemoteConfiguration", - code: code, - userInfo: [NSLocalizedDescriptionKey: "HTTP \(code)"] - )) - return - } - // 3. Empty body - guard let data = data, !data.isEmpty else { - telemetry.error("[RemoteConfig] Empty response body") - return - } - - // TODO RUM-16387: Validate the schema before saving - - // All checks passed — persist to disk - if let error = cache.save(data) { - telemetry.error("[RemoteConfig] Failed to write remote configuration to disk", error: error) + let endpoint = site.remoteConfigurationEndpoint + .appendingPathComponent("v1") + .appendingPathComponent(id) + .appendingPathExtension("json") + + httpClient.fetch(request: URLRequest(url: endpoint)) { result in + switch result { + case .failure(let error): + completionHandler(.failure(error)) + + case .success(let (http, data)): + // 1. Non-2xx HTTP status + guard (200..<300).contains(http.statusCode) else { + completionHandler(.failure(RemoteConfigurationError.httpError(http.statusCode))) + return + } + + // 2. Empty body + guard !data.isEmpty else { + completionHandler(.failure(RemoteConfigurationError.emptyBody)) + return + } + + // TODO RUM-16387: Validate the schema before saving + + // All checks passed — persist to disk and update in-memory cache. + // createFile creates or atomically replaces the file, so no existence check needed. + do { + let file = try self.directory.createFile(named: "\(self.id).json") + try file.write(data: data) + self.cache = .success(data) + completionHandler(.success(data)) + } catch { + completionHandler(.failure(error)) + } } } - task.resume() } } + +private enum RemoteConfigurationError: Error { + case httpError(Int) + case emptyBody +} diff --git a/DatadogCore/Sources/Core/Storage/Files/File.swift b/DatadogCore/Sources/Core/Storage/Files/File.swift index 2f7a430673..e780ccb075 100644 --- a/DatadogCore/Sources/Core/Storage/Files/File.swift +++ b/DatadogCore/Sources/Core/Storage/Files/File.swift @@ -122,6 +122,10 @@ internal struct File: WritableFile, ReadableFile, FileProtocol, Equatable { return stream } + func read() throws -> Data { + return try Data(contentsOf: url) + } + func size() throws -> UInt64 { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) return attributes[.size] as? UInt64 ?? 0 diff --git a/DatadogCore/Sources/Core/Upload/HTTPClient.swift b/DatadogCore/Sources/Core/Upload/HTTPClient.swift index f881608215..edd335567c 100644 --- a/DatadogCore/Sources/Core/Upload/HTTPClient.swift +++ b/DatadogCore/Sources/Core/Upload/HTTPClient.swift @@ -14,6 +14,12 @@ internal protocol HTTPClient { /// - delegate: The task-specific delegate. /// - completion: A closure that receives a Result containing either an HTTPURLResponse or an Error. func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) + + /// Fetches the provided request using HTTP and returns both the response and the response body. + /// - Parameters: + /// - request: The request to be sent. + /// - completion: A closure that receives a Result containing either an (HTTPURLResponse, Data) pair or an Error. + func fetch(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data), Error>) -> Void) } extension HTTPClient { @@ -24,4 +30,9 @@ extension HTTPClient { func send(request: URLRequest, completion: @escaping (Result) -> Void) { self.send(request: request, delegate: nil, completion: completion) } + + /// Default no-op — concrete types that support response body data must provide their own implementation. + func fetch(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data), Error>) -> Void) { + completion(.failure(NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "fetch(_:) not implemented"]))) + } } diff --git a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift index 924ecb8535..474f41366a 100644 --- a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift +++ b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift @@ -35,6 +35,21 @@ internal class URLSessionClient: HTTPClient { self.session = session } + func fetch(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data), Error>) -> Void) { + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + guard let http = response as? HTTPURLResponse, let data = data else { + completion(.failure(URLSessionTransportInconsistencyException())) + return + } + completion(.success((http, data))) + } + task.resume() + } + func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) { let task = session.dataTask(with: request) { data, response, error in completion(httpClientResult(for: (data, response, error))) diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index f7dfe5e67e..17327a3bba 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -462,14 +462,13 @@ extension DatadogCore { maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled, isRunFromExtension: isRunFromExtension, - remoteConfigurationID: configuration.remoteConfigurationID + remoteConfiguration: configuration.remoteConfigurationID.map { ($0, configuration.site) } ) - if let synchronizer = self.synchronizer, - let id = configuration.remoteConfigurationID, - let host = configuration.site.remoteConfigurationHost, - let endpoint = RemoteConfigurationSynchronizer.endpoint(for: id, host: host) { - synchronizer.start(from: endpoint, connectionProxyDictionary: configuration.proxyConfiguration, telemetry: telemetry) + synchronizer?.sync { [weak self] result in + if case .failure(let error) = result { + self?.telemetry.error("[RemoteConfig] Sync failed", error: error) + } } telemetry.configuration( diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift deleted file mode 100644 index cbbb0bac5e..0000000000 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationCacheTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 2019-Present Datadog, Inc. - */ - -import XCTest -import TestUtilities -@testable import DatadogCore - -class RemoteConfigurationCacheTests: XCTestCase { - private var coreDir = temporaryUniqueCoreDirectory() - - override func setUp() { - super.setUp() - coreDir = temporaryUniqueCoreDirectory() - coreDir.create() - } - - override func tearDown() { - coreDir.delete() - super.tearDown() - } - - // MARK: Read at init - - func testReturnsNilWhenNoCacheExists() { - let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertNil(cache.data) - XCTAssertNil(cache.loadError) - } - - // MARK: Persistence across instances (simulates app relaunch) - - func testDataReadBackOnNextInit() { - let payload = Data("{\"session_sample_rate\":50}".utf8) - - // First "launch": save data - let cache1 = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - cache1.save(payload) - - // Second "launch": a fresh instance must read it back - let cache2 = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertEqual(cache2.data, payload) - XCTAssertNil(cache2.loadError) - } - - func testSaveOverwritesPreviousFile() { - let first = Data("{\"v\":1}".utf8) - let second = Data("{\"v\":2}".utf8) - - RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).save(first) - RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).save(second) - - let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertEqual(cache.data, second) - } - - func testDifferentIDsUseDifferentFiles() { - let payload1 = Data("{\"v\":1}".utf8) - let payload2 = Data("{\"v\":2}".utf8) - - RemoteConfigurationCache(id: "id-one", directory: coreDir.coreDirectory).save(payload1) - RemoteConfigurationCache(id: "id-two", directory: coreDir.coreDirectory).save(payload2) - - XCTAssertEqual(RemoteConfigurationCache(id: "id-one", directory: coreDir.coreDirectory).data, payload1) - XCTAssertEqual(RemoteConfigurationCache(id: "id-two", directory: coreDir.coreDirectory).data, payload2) - } - - // MARK: Failure resilience - - func testSaveReturnsNilOnSuccess() { - let cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertNil(cache.save(Data("{\"k\":\"v\"}".utf8))) - } - - func testSaveReturnsErrorWhenDirectoryMissing() { - let missing = Directory(url: URL(fileURLWithPath: "/no/such/path/")) - let cache = RemoteConfigurationCache(id: "test-id", directory: missing) - XCTAssertNotNil(cache.save(Data("{\"k\":\"v\"}".utf8))) - } - - func testLoadErrorSetWhenFileIsCorrupt() throws { - // Write a valid file, then revoke read permission so Data(contentsOf:) fails. - let id = "corrupt-id" - let fileURL = coreDir.coreDirectory.url.appendingPathComponent("\(id).json") - try Data("{\"v\":1}".utf8).write(to: fileURL, options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fileURL.path) - defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) } - - let cache = RemoteConfigurationCache(id: id, directory: coreDir.coreDirectory) - XCTAssertNil(cache.data) - XCTAssertNotNil(cache.loadError) - } -} diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift deleted file mode 100644 index 922d719e26..0000000000 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationFetcherTests.swift +++ /dev/null @@ -1,243 +0,0 @@ -/* - * 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 2019-Present Datadog, Inc. - */ - -import XCTest -import TestUtilities -import DatadogInternal -@testable import DatadogCore - -// MARK: MockURLProtocol - -class MockURLProtocol: URLProtocol { - /// Set this before each test to control what the mock returns. - static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let handler = MockURLProtocol.requestHandler else { - client?.urlProtocolDidFinishLoading(self) - return - } - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - if let data = data { client?.urlProtocol(self, didLoad: data) } - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} - -// MARK: Helper - -func mockSession() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - return URLSession(configuration: config) -} - -private func okResponse(for url: URL) -> HTTPURLResponse { - HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! -} - -private func errorResponse(for url: URL, status: Int) -> HTTPURLResponse { - HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)! -} - -// MARK: Tests - -class RemoteConfigurationFetcherTests: XCTestCase { - private var coreDir: CoreDirectory! // swiftlint:disable:this implicitly_unwrapped_optional - private var cache: RemoteConfigurationCache! // swiftlint:disable:this implicitly_unwrapped_optional - private let endpoint = URL(string: "https://example.com/remote-config")! - - override func setUp() { - coreDir = temporaryUniqueCoreDirectory() - coreDir.create() - cache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - } - - override func tearDown() { - MockURLProtocol.requestHandler = nil - coreDir.delete() - } - - // MARK: Success path - - func testSuccessfulFetchWritesToCache() { - let payload = Data("{\"session_sample_rate\":50}".utf8) - - let expectedEndpoint = endpoint - MockURLProtocol.requestHandler = { request in - XCTAssertEqual(request.url, expectedEndpoint) - return (okResponse(for: request.url!), payload) - } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) - - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - let freshCache = RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory) - XCTAssertEqual(freshCache.data, payload, "Cache must contain the CDN response") - XCTAssertFalse( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "No telemetry errors expected on success" - ) - } - - // MARK: Failure paths cache must not be overwritten - - func testNetworkErrorDoesNotOverwriteExistingCache() { - let existing = Data("{\"v\":1}".utf8) - cache.save(existing) - - MockURLProtocol.requestHandler = { _ in throw URLError(.networkConnectionLost) } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - XCTAssertEqual( - RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, - existing, - "Existing cache must be preserved after a network error" - ) - XCTAssertTrue( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "A telemetry error must be reported" - ) - } - - func testNon2xxResponseDoesNotOverwriteExistingCache() { - let existing = Data("{\"v\":1}".utf8) - cache.save(existing) - - MockURLProtocol.requestHandler = { request in - (errorResponse(for: request.url!, status: 500), nil) - } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - XCTAssertEqual(RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, existing) - XCTAssertTrue( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "A telemetry error must be reported" - ) - } - - func testEmptyBodyDoesNotOverwriteExistingCache() { - let existing = Data("{\"v\":1}".utf8) - cache.save(existing) - - MockURLProtocol.requestHandler = { request in - (okResponse(for: request.url!), Data()) // empty body - } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - XCTAssertEqual(RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, existing) - XCTAssertTrue( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "A telemetry error must be reported" - ) - } - - func testNonJSONBodyIsSavedToCache() { - // JSON schema validation is deferred (TODO RUM-16387), so any non-empty 2xx - // body is accepted and written to cache as-is. - let nonJSON = Data("this is not json".utf8) - - MockURLProtocol.requestHandler = { request in - (okResponse(for: request.url!), nonJSON) - } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: cache, telemetry: telemetry, session: mockSession()) - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - XCTAssertEqual( - RemoteConfigurationCache(id: "test-id", directory: coreDir.coreDirectory).data, - nonJSON, - "Non-JSON body must be saved until schema validation is implemented" - ) - XCTAssertFalse( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "No telemetry error expected when body is non-empty and status is 2xx" - ) - } - - func testDiskWriteFailureReportsTelemetry() { - // Use a cache pointing at a non-existent directory so the write will fail - let missingDir = Directory(url: URL(fileURLWithPath: "/no/such/path/")) - let brokenCache = RemoteConfigurationCache(id: "test-id", directory: missingDir) - - MockURLProtocol.requestHandler = { request in - (okResponse(for: request.url!), Data("{}".utf8)) - } - - let telemetry = TelemetryMock() - let expectation = expectation(description: "fetch completes") - let fetcher = RemoteConfigurationFetcher(cache: brokenCache, telemetry: telemetry, session: mockSession()) - fetcher.fetch(from: endpoint, didComplete: { expectation.fulfill() }) - wait(for: [expectation], timeout: 2) - - XCTAssertTrue( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "A telemetry error must be reported when the disk write fails" - ) - } -} diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift index 69ea7b45fd..1eb9230fc4 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift @@ -6,139 +6,291 @@ import XCTest import TestUtilities +import DatadogInternal @testable import DatadogCore +// MARK: - MockURLProtocol + +private class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocolDidFinishLoading(self) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { client?.urlProtocol(self, didLoad: data) } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private func mockSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) +} + // MARK: - Tests class RemoteConfigurationTests: XCTestCase { private var coreDir: CoreDirectory! // swiftlint:disable:this implicitly_unwrapped_optional - private var session: URLSession? + private var httpClient: URLSessionClient! // swiftlint:disable:this implicitly_unwrapped_optional override func setUp() { super.setUp() coreDir = temporaryUniqueCoreDirectory() coreDir.create() - session = mockSession() + httpClient = URLSessionClient(session: mockSession()) } override func tearDown() { MockURLProtocol.requestHandler = nil - session?.invalidateAndCancel() - session = nil + httpClient.session.invalidateAndCancel() + httpClient = nil coreDir.delete() super.tearDown() } - // MARK: endpoint(for:host:) + private func makeSynchronizer(id: String = "test-id") -> RemoteConfigurationSynchronizer { + RemoteConfigurationSynchronizer( + id: id, + site: .us1, + directory: coreDir.coreDirectory, + httpClient: httpClient + ) + } + + // MARK: endpoint URL construction func testEndpointBuildsCorrectURL() { - let url = RemoteConfigurationSynchronizer.endpoint(for: "abc-123", host: "sdk-configuration.browser-intake-datadoghq.com") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/abc-123.json") + let url = DatadogSite.us1.remoteConfigurationEndpoint + .appendingPathComponent("v1") + .appendingPathComponent("abc-123") + .appendingPathExtension("json") + XCTAssertEqual(url.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/abc-123.json") } func testEndpointPercentEncodesSpacesInID() { - let url = RemoteConfigurationSynchronizer.endpoint(for: "hello world", host: "sdk-configuration.browser-intake-datadoghq.com") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/hello%20world.json") - } - - func testEndpointEncodesSlashSoItDoesNotProduceExtraPathSegments() { - let url = RemoteConfigurationSynchronizer.endpoint(for: "a/b", host: "sdk-configuration.browser-intake-datadoghq.com") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/a%2Fb.json") + let url = DatadogSite.us1.remoteConfigurationEndpoint + .appendingPathComponent("v1") + .appendingPathComponent("hello world") + .appendingPathExtension("json") + XCTAssertEqual(url.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/hello%20world.json") } func testEndpointEncodesQuestionMarkSoItDoesNotProduceQueryString() { - let url = RemoteConfigurationSynchronizer.endpoint(for: "id?query", host: "sdk-configuration.browser-intake-datadoghq.com") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%3Fquery.json") + let url = DatadogSite.us1.remoteConfigurationEndpoint + .appendingPathComponent("v1") + .appendingPathComponent("id?query") + .appendingPathExtension("json") + XCTAssertEqual(url.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%3Fquery.json") } func testEndpointEncodesHashSoItDoesNotProduceFragment() { - let url = RemoteConfigurationSynchronizer.endpoint(for: "id#section", host: "sdk-configuration.browser-intake-datadoghq.com") - XCTAssertEqual(url?.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%23section.json") + let url = DatadogSite.us1.remoteConfigurationEndpoint + .appendingPathComponent("v1") + .appendingPathComponent("id#section") + .appendingPathExtension("json") + XCTAssertEqual(url.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/v1/id%23section.json") } // MARK: Init - func testInitCreatesCacheForGivenID() { - let rc = RemoteConfigurationSynchronizer(id: "abc", directory: coreDir.coreDirectory) - // Cache is created — no data yet on first launch, no error either. - XCTAssertNil(rc.cache.data) - XCTAssertNil(rc.cache.loadError) + func testInitCacheIsEmptyOnFirstLaunch() { + let rc = makeSynchronizer() + guard case .failure = rc.cache else { + return XCTFail("Expected cache to be .failure on first launch") + } } - // MARK: start() + func testInitReadsCacheFromPreviousLaunch() throws { + let payload = Data("{\"session_sample_rate\":50}".utf8) + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("test-id.json") + try payload.write(to: fileURL, options: .atomic) + + let rc = makeSynchronizer() + XCTAssertEqual(try rc.cache.get(), payload) + } - func testStartReportsLoadErrorViaTelemetry() throws { - // Given — write a file then revoke read permission so the cache init fails to read it. - let id = "error-id" - let fileURL = coreDir.coreDirectory.url.appendingPathComponent("\(id).json") + func testInitCacheIsFailureWhenFileIsUnreadable() throws { + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("test-id.json") try Data("{\"v\":1}".utf8).write(to: fileURL, options: .atomic) try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fileURL.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fileURL.path) } - let rc = RemoteConfigurationSynchronizer(id: id, directory: coreDir.coreDirectory) - XCTAssertNotNil(rc.cache.loadError, "Precondition: cache must have a loadError") - - // When - let telemetry = TelemetryMock() - rc.start(from: URL(string: "https://example.com")!, connectionProxyDictionary: nil, telemetry: telemetry, session: session) - - // Then - XCTAssertTrue( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "start() must report the load error via telemetry" - ) + let rc = makeSynchronizer() + guard case .failure = rc.cache else { + return XCTFail("Expected cache to be .failure when file is unreadable") + } } - func testStartWithNoLoadErrorReportsNoTelemetryError() { - // Given — fresh cache, no previous file on disk. - // Return a successful response so the async fetch never reports an error regardless of timing. + // MARK: sync() + + func testSyncReturnsSuccessAndPopulatesCache() { + let payload = Data("{\"session_sample_rate\":50}".utf8) MockURLProtocol.requestHandler = { request in - (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) } - let rc = RemoteConfigurationSynchronizer(id: "clean-id", directory: coreDir.coreDirectory) - XCTAssertNil(rc.cache.loadError, "Precondition: no load error expected") - - // When - let telemetry = TelemetryMock() - rc.start(from: URL(string: "https://example.com")!, connectionProxyDictionary: nil, telemetry: telemetry, session: session) - - // Then — no telemetry error from the load path - XCTAssertFalse( - telemetry.messages.contains { - if case .error = $0 { - return true - } - return false - }, - "start() must not report a telemetry error when there is no load error" - ) + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + + rc.sync { result in + XCTAssertEqual(try? result.get(), payload) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(try? rc.cache.get(), payload) } - func testStartFetchesAndPopulatesCache() { - // Given + func testSyncPersistsCacheAcrossInstances() throws { let payload = Data("{\"session_sample_rate\":50}".utf8) MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) } - let rc = RemoteConfigurationSynchronizer(id: "fetch-id", directory: coreDir.coreDirectory) - let expectation = expectation(description: "fetch completes") + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + rc.sync { _ in expectation.fulfill() } + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(try? makeSynchronizer().cache.get(), payload) + } + + func testSyncNetworkErrorReturnsFailureAndLeavesCache() { + MockURLProtocol.requestHandler = { _ in throw URLError(.networkConnectionLost) } + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + + rc.sync { result in + guard case .failure = result else { + return XCTFail("Expected failure on network error") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) - // When - rc.start( - from: URL(string: "https://example.com")!, - connectionProxyDictionary: nil, - telemetry: TelemetryMock(), - session: session, - didComplete: { expectation.fulfill() } + guard case .failure = rc.cache else { + return XCTFail("Cache must remain .failure after network error") + } + } + + func testSyncNon2xxReturnsFailureAndPreservesCache() throws { + let existing = Data("{\"v\":1}".utf8) + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("test-id.json") + try existing.write(to: fileURL, options: .atomic) + + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, nil) + } + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + + rc.sync { result in + guard case .failure = result else { + return XCTFail("Expected failure on non-2xx") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + + XCTAssertEqual(try? makeSynchronizer().cache.get(), existing, "Existing cache must be preserved after non-2xx") + } + + func testSyncEmptyBodyReturnsFailureAndLeavesCache() { + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data()) + } + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + + rc.sync { result in + guard case .failure = result else { + return XCTFail("Expected failure on empty body") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + + guard case .failure = rc.cache else { + return XCTFail("Cache must remain .failure after empty body response") + } + } + + func testSyncNonJSONBodyIsSavedToCache() { + // JSON schema validation is deferred (TODO RUM-16387), so any non-empty 2xx + // body is accepted and written to cache as-is. + let nonJSON = Data("this is not json".utf8) + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, nonJSON) + } + let rc = makeSynchronizer() + let expectation = expectation(description: "sync completes") + + rc.sync { result in + XCTAssertEqual(try? result.get(), nonJSON) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + } + + func testSyncDiskWriteFailureReturnsFailure() { + MockURLProtocol.requestHandler = { request in + (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) + } + let missingDir = Directory(url: URL(fileURLWithPath: "/no/such/path/")) + let rc = RemoteConfigurationSynchronizer( + id: "test-id", + site: .us1, + directory: missingDir, + httpClient: httpClient ) + let expectation = expectation(description: "sync completes") + + rc.sync { result in + guard case .failure = result else { + return XCTFail("Expected failure on disk write error") + } + expectation.fulfill() + } wait(for: [expectation], timeout: 2) - // Then — the fetched payload must be written to cache - XCTAssertEqual(rc.cache.data, payload, "cache.data must be populated after a successful fetch") + guard case .failure = rc.cache else { + return XCTFail("Cache must remain .failure after disk write error") + } + } + + func testDifferentIDsUseDifferentFiles() throws { + let payload1 = Data("{\"v\":1}".utf8) + let payload2 = Data("{\"v\":2}".utf8) + + MockURLProtocol.requestHandler = { _ in + (HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload1) + } + let rc1 = makeSynchronizer(id: "id-one") + let exp1 = expectation(description: "sync 1 completes") + rc1.sync { _ in exp1.fulfill() } + wait(for: [exp1], timeout: 2) + + MockURLProtocol.requestHandler = { _ in + (HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload2) + } + let rc2 = makeSynchronizer(id: "id-two") + let exp2 = expectation(description: "sync 2 completes") + rc2.sync { _ in exp2.fulfill() } + wait(for: [exp2], timeout: 2) + + XCTAssertEqual(try? makeSynchronizer(id: "id-one").cache.get(), payload1) + XCTAssertEqual(try? makeSynchronizer(id: "id-two").cache.get(), payload2) } } diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index e1921a4c53..e810f07b61 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -34,27 +34,29 @@ public enum DatadogSite: String { } extension DatadogSite { - public var endpoint: URL { + /// The intake hostname for this site (e.g. `browser-intake-datadoghq.com`). + public var host: String { switch self { - // swiftlint:disable force_unwrapping - case .us1: return URL(string: "https://browser-intake-datadoghq.com/")! - case .us3: return URL(string: "https://browser-intake-us3-datadoghq.com/")! - case .us5: return URL(string: "https://browser-intake-us5-datadoghq.com/")! - case .eu1: return URL(string: "https://browser-intake-datadoghq.eu/")! - case .ap1: return URL(string: "https://browser-intake-ap1-datadoghq.com/")! - case .ap2: return URL(string: "https://browser-intake-ap2-datadoghq.com/")! - case .us1_fed: return URL(string: "https://browser-intake-ddog-gov.com/")! - case .us2_fed: return URL(string: "https://browser-intake-us2-ddog-gov.com/")! - // swiftlint:enable force_unwrapping + case .us1: return "browser-intake-datadoghq.com" + case .us3: return "browser-intake-us3-datadoghq.com" + case .us5: return "browser-intake-us5-datadoghq.com" + case .eu1: return "browser-intake-datadoghq.eu" + case .ap1: return "browser-intake-ap1-datadoghq.com" + case .ap2: return "browser-intake-ap2-datadoghq.com" + case .us1_fed: return "browser-intake-ddog-gov.com" + case .us2_fed: return "browser-intake-us2-ddog-gov.com" } } - /// The CDN hostname used for fetching remote configuration documents. - /// Format: `sdk-configuration.` - /// - /// The full URL (with path and ID) is constructed by `RemoteConfigurationSynchronizer`. - @_spi(Internal) - public var remoteConfigurationHost: String? { - endpoint.host.map { "sdk-configuration.\($0)" } + public var endpoint: URL { + // swiftlint:disable:next force_unwrapping + URL(string: "https://\(host)/")! + } + + /// The base CDN URL for fetching remote configuration documents. + /// The full URL (with API version path and ID) is constructed by `RemoteConfigurationSynchronizer`. + public var remoteConfigurationEndpoint: URL { + // swiftlint:disable:next force_unwrapping + URL(string: "https://sdk-configuration.\(host)/")! } } diff --git a/DatadogInternal/Tests/Context/DatadogSiteTests.swift b/DatadogInternal/Tests/Context/DatadogSiteTests.swift index 5ef513a50b..8fbceb2c44 100644 --- a/DatadogInternal/Tests/Context/DatadogSiteTests.swift +++ b/DatadogInternal/Tests/Context/DatadogSiteTests.swift @@ -5,41 +5,62 @@ */ import XCTest -@_spi(Internal) import DatadogInternal class DatadogSiteTests: XCTestCase { - // MARK: - remoteConfigurationHost per site + // MARK: - host per site - func testUS1RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.us1.remoteConfigurationHost, "sdk-configuration.browser-intake-datadoghq.com") + func testUS1Host() { XCTAssertEqual(DatadogSite.us1.host, "browser-intake-datadoghq.com") } + func testUS3Host() { XCTAssertEqual(DatadogSite.us3.host, "browser-intake-us3-datadoghq.com") } + func testUS5Host() { XCTAssertEqual(DatadogSite.us5.host, "browser-intake-us5-datadoghq.com") } + func testEU1Host() { XCTAssertEqual(DatadogSite.eu1.host, "browser-intake-datadoghq.eu") } + func testAP1Host() { XCTAssertEqual(DatadogSite.ap1.host, "browser-intake-ap1-datadoghq.com") } + func testAP2Host() { XCTAssertEqual(DatadogSite.ap2.host, "browser-intake-ap2-datadoghq.com") } + func testUS1FedHost() { XCTAssertEqual(DatadogSite.us1_fed.host, "browser-intake-ddog-gov.com") } + func testUS2FedHost() { XCTAssertEqual(DatadogSite.us2_fed.host, "browser-intake-us2-ddog-gov.com") } + + // MARK: - endpoint per site + + func testUS1Endpoint() { XCTAssertEqual(DatadogSite.us1.endpoint.absoluteString, "https://browser-intake-datadoghq.com/") } + func testUS3Endpoint() { XCTAssertEqual(DatadogSite.us3.endpoint.absoluteString, "https://browser-intake-us3-datadoghq.com/") } + func testUS5Endpoint() { XCTAssertEqual(DatadogSite.us5.endpoint.absoluteString, "https://browser-intake-us5-datadoghq.com/") } + func testEU1Endpoint() { XCTAssertEqual(DatadogSite.eu1.endpoint.absoluteString, "https://browser-intake-datadoghq.eu/") } + func testAP1Endpoint() { XCTAssertEqual(DatadogSite.ap1.endpoint.absoluteString, "https://browser-intake-ap1-datadoghq.com/") } + func testAP2Endpoint() { XCTAssertEqual(DatadogSite.ap2.endpoint.absoluteString, "https://browser-intake-ap2-datadoghq.com/") } + func testUS1FedEndpoint() { XCTAssertEqual(DatadogSite.us1_fed.endpoint.absoluteString, "https://browser-intake-ddog-gov.com/") } + func testUS2FedEndpoint() { XCTAssertEqual(DatadogSite.us2_fed.endpoint.absoluteString, "https://browser-intake-us2-ddog-gov.com/") } + + // MARK: - remoteConfigurationEndpoint per site + + func testUS1RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us1.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.com/") } - func testUS3RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.us3.remoteConfigurationHost, "sdk-configuration.browser-intake-us3-datadoghq.com") + func testUS3RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us3.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us3-datadoghq.com/") } - func testUS5RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.us5.remoteConfigurationHost, "sdk-configuration.browser-intake-us5-datadoghq.com") + func testUS5RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us5.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us5-datadoghq.com/") } - func testEU1RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.eu1.remoteConfigurationHost, "sdk-configuration.browser-intake-datadoghq.eu") + func testEU1RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.eu1.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.eu/") } - func testAP1RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.ap1.remoteConfigurationHost, "sdk-configuration.browser-intake-ap1-datadoghq.com") + func testAP1RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.ap1.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ap1-datadoghq.com/") } - func testAP2RemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.ap2.remoteConfigurationHost, "sdk-configuration.browser-intake-ap2-datadoghq.com") + func testAP2RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.ap2.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ap2-datadoghq.com/") } - func testUS1FedRemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.us1_fed.remoteConfigurationHost, "sdk-configuration.browser-intake-ddog-gov.com") + func testUS1FedRemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us1_fed.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ddog-gov.com/") } - func testUS2FedRemoteConfigurationHost() { - XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationHost, "sdk-configuration.browser-intake-us2-ddog-gov.com") + func testUS2FedRemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us2-ddog-gov.com/") } }