diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index ffa9c4fc41..d620bd26e3 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -19,7 +19,7 @@ 09A936A12F0EB989000B6379 /* SamplingPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369B2F0EB989000B6379 /* SamplingPriority.swift */; }; 09A936A22F0EB989000B6379 /* SpanContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9369C2F0EB989000B6379 /* SpanContext.swift */; }; 1019DFD92FB1BB2E006599B4 /* DatadogSiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */; }; - 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */; }; + 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E906292FBDB818002D5F45 /* RemoteConfigurationProvider.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 */; }; @@ -1775,7 +1775,7 @@ 09A9369C2F0EB989000B6379 /* SpanContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpanContext.swift; sourceTree = ""; }; 0D2A20EFB1D82D0B8AC781F9 /* HeatmapMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMocks.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 = ""; }; + 10E906292FBDB818002D5F45 /* RemoteConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationProvider.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 = ""; }; @@ -4464,7 +4464,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( - 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */, + 10E906292FBDB818002D5F45 /* RemoteConfigurationProvider.swift */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -7922,7 +7922,7 @@ 617699182A860D9D0030022B /* HTTPClient.swift in Sources */, D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */, 61133BD42423979B00786299 /* FileReader.swift in Sources */, - 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */, + 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationProvider.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 5c018e7c0d..925db8d547 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,9 +55,13 @@ internal final class DatadogCore { /// The message-bus instance. let bus = MessageBus() - /// Owns the remote configuration cache and fetch lifecycle. - /// `nil` when `remoteConfiguration` was not set at init. - internal let synchronizer: RemoteConfigurationSynchronizer? + /// The remote configuration provider, if configured. + var remoteConfigurationProvider: RemoteConfigurationProvider? + + /// The last successfully fetched remote configuration, if any. + var remoteConfiguration: RemoteConfiguration? { + remoteConfigurationProvider?.remoteConfiguration + } /// Registry for Features. @ReadWriteLock @@ -79,10 +83,6 @@ internal final class DatadogCore { /// Maximum number of batches per upload. internal let maxBatchesPerUpload: Int - /// The last successfully fetched and cached remote configuration, if any. - @ReadWriteLock - var remoteConfiguration: RemoteConfiguration? = nil - /// Creates a core instance. /// /// - Parameters: @@ -94,7 +94,7 @@ internal final class DatadogCore { /// - encryption: The on-disk data encryption. /// - contextProvider: The core context provider. /// - applicationVersion: The application version. - /// - remoteConfiguration: The remote configuration ID and site; `nil` when not configured. + /// - remoteConfigurationProvider: The remote configuration provider, if configured. init( directory: CoreDirectory, dateProvider: DateProvider, @@ -107,10 +107,11 @@ internal final class DatadogCore { maxBatchesPerUpload: Int, backgroundTasksEnabled: Bool, isRunFromExtension: Bool = false, - remoteConfiguration: (id: String, site: DatadogSite)? = nil + remoteConfigurationProvider: RemoteConfigurationProvider? = nil ) { self.directory = directory self.dateProvider = dateProvider + self.remoteConfigurationProvider = remoteConfigurationProvider self.performance = performance self.httpClient = httpClient self.encryption = encryption @@ -124,9 +125,6 @@ internal final class DatadogCore { self.contextProvider.subscribe(\.accountInfo, to: accountInfoPublisher) self.contextProvider.subscribe(\.version, to: applicationVersionPublisher) self.contextProvider.subscribe(\.trackingConsent, to: consentPublisher) - 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/RemoteConfigurationProvider.swift similarity index 60% rename from DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift rename to DatadogCore/Sources/Core/RemoteConfigurationProvider.swift index 2722e711c1..f1f61312b8 100644 --- a/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift +++ b/DatadogCore/Sources/Core/RemoteConfigurationProvider.swift @@ -7,44 +7,75 @@ import Foundation import DatadogInternal -/// Owns the remote configuration cache and fetch lifecycle. -/// -/// Created by `DatadogCore` during initialization when a `remoteConfigurationID` is provided. -/// Call `sync(_:)` to fire a CDN fetch and update the cache. +/// Provides the last successfully fetched remote configuration and refreshes its cache. /// /// 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 { +internal final class RemoteConfigurationProvider { let id: String let site: DatadogSite let directory: Directory let httpClient: HTTPClient + private let telemetry: Telemetry /// The result of the last cache read or CDN fetch. - /// `.success(data)` — data is available. + /// `.success(remoteConfiguration)` — remote configuration is available. /// `.failure` — no cache yet, or a read/write error. @ReadWriteLock - private(set) var cache: Result + private(set) var cache: Result + + var remoteConfiguration: RemoteConfiguration? { + try? cache.get() + } - init(id: String, site: DatadogSite, directory: Directory, httpClient: HTTPClient) { + init( + id: String, + site: DatadogSite, + directory: Directory, + httpClient: HTTPClient, + telemetry: Telemetry = NOPTelemetry() + ) { self.id = id self.site = site self.directory = directory self.httpClient = httpClient + self.telemetry = telemetry // 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._cache = ReadWriteLock(wrappedValue: Self.readCache(id: id, from: directory)) + self._cache = ReadWriteLock(wrappedValue: Self.readCache(id: id, from: directory, telemetry: telemetry)) } // MARK: - Private - private static func readCache(id: String, from directory: Directory) -> Result { + private static func readCache( + id: String, + from directory: Directory, + telemetry: Telemetry + ) -> Result { + let fileName = "\(id).json" + guard directory.hasFile(named: fileName) else { + return .failure(.diskError) + } + + let data: Data do { - return .success(try directory.file(named: "\(id).json").read()) + data = try directory.file(named: fileName).read() } catch { + let error = RemoteConfigurationError.diskError + telemetry.error("[RemoteConfig] Cache read failed", error: error) return .failure(error) } + + return decode(data) + } + + private static func decode(_ data: Data) -> Result { + do { + return .success(try JSONDecoder().decode(RemoteConfiguration.self, from: data)) + } catch { + return .failure(.decodingError(error)) + } } // MARK: - Internal @@ -52,7 +83,7 @@ internal final class RemoteConfigurationSynchronizer { /// Fires an async CDN fetch and updates the cache on success. /// /// - Parameter completionHandler: Called with the fetch result when the operation (and any cache write) is done. - func sync(_ completionHandler: @escaping (Result) -> Void) { + func sync(_ completionHandler: @escaping (Result) -> Void) { // Build request with conditional ETag header if a previous ETag is stored. // Only send If-None-Match when the cache is usable — if cache is .failure, // a 304 response would leave us with no data to serve. @@ -67,10 +98,10 @@ internal final class RemoteConfigurationSynchronizer { request.setValue(etag, forHTTPHeaderField: "If-None-Match") } - httpClient.fetch(request: request) { result in + httpClient.send(request: request, delegate: nil) { result in switch result { case .failure(let error): - completionHandler(.failure(error)) + completionHandler(.failure(.networkError(error))) case .success(let (http, data)): // 1. Not Modified — existing cache is still valid @@ -81,24 +112,31 @@ internal final class RemoteConfigurationSynchronizer { // 2. Non-2xx HTTP status guard (200..<300).contains(http.statusCode) else { - completionHandler(.failure(RemoteConfigurationError.httpError(http.statusCode))) + completionHandler(.failure(.httpError(http.statusCode))) return } // 3. Empty body - guard !data.isEmpty else { - completionHandler(.failure(RemoteConfigurationError.emptyBody)) + guard let data = data, !data.isEmpty else { + completionHandler(.failure(.emptyBody)) return } - // TODO RUM-16387: Validate the schema before saving + let remoteConfiguration: RemoteConfiguration + switch Self.decode(data) { + case .success(let decodedRemoteConfiguration): + remoteConfiguration = decodedRemoteConfiguration + case .failure(let error): + completionHandler(.failure(error)) + return + } // All checks passed — persist to disk and update in-memory cache. // File.write uses .atomic (write to temp, then rename), so the update is // all-or-nothing: the existing file is never left in a truncated state. do { try File(url: self.directory.url.appendingPathComponent("\(self.id).json")).write(data: data) - self.cache = .success(data) + self.cache = .success(remoteConfiguration) // Store ETag for conditional requests on the next sync. // If the response has no ETag, delete any stale validator so we @@ -114,23 +152,29 @@ internal final class RemoteConfigurationSynchronizer { try? self.directory.file(named: etagFileName).delete() } - completionHandler(.success(data)) + completionHandler(.success(remoteConfiguration)) } catch { - completionHandler(.failure(error)) + completionHandler(.failure(.diskError)) } } } } } -private enum RemoteConfigurationError: Error, LocalizedError { +internal enum RemoteConfigurationError: Error, LocalizedError { + case networkError(Error) case httpError(Int) case emptyBody + case decodingError(Error) + case diskError var errorDescription: String? { switch self { + case .networkError(let error): return "Network error: \(error.localizedDescription)" case .httpError(let code): return "Non-2xx response: HTTP \(code)" case .emptyBody: return "Empty response body" + case .decodingError(let error): return "Remote configuration decoding failed: \(error.localizedDescription)" + case .diskError: return "Remote configuration disk read/write failed" } } } diff --git a/DatadogCore/Sources/Core/Upload/DataUploader.swift b/DatadogCore/Sources/Core/Upload/DataUploader.swift index a684c7e5e7..86784dc97e 100644 --- a/DatadogCore/Sources/Core/Upload/DataUploader.swift +++ b/DatadogCore/Sources/Core/Upload/DataUploader.swift @@ -66,7 +66,7 @@ internal final class DataUploader: DataUploaderType { httpClient.send(request: request, delegate: delegate) { result in switch result { - case .success(let httpResponse): + case .success(let (httpResponse, _)): uploadStatus = DataUploadStatus( httpResponse: httpResponse, ddRequestID: requestID, diff --git a/DatadogCore/Sources/Core/Upload/HTTPClient.swift b/DatadogCore/Sources/Core/Upload/HTTPClient.swift index edd335567c..722ebe5783 100644 --- a/DatadogCore/Sources/Core/Upload/HTTPClient.swift +++ b/DatadogCore/Sources/Core/Upload/HTTPClient.swift @@ -12,27 +12,20 @@ internal protocol HTTPClient { /// - Parameters: /// - request: The request to be sent. /// - 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) + /// - completion: A closure that receives a Result containing either the HTTP response and body or an Error. + func send( + request: URLRequest, + delegate: URLSessionTaskDelegate?, + completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void + ) } extension HTTPClient { /// Sends the provided request using HTTP. /// - Parameters: /// - request: The request to be sent. - /// - completion: A closure that receives a Result containing either an HTTPURLResponse or an Error. - func send(request: URLRequest, completion: @escaping (Result) -> Void) { + /// - completion: A closure that receives a Result containing either the HTTP response and body or an Error. + func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> 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 474f41366a..f60f05891e 100644 --- a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift +++ b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift @@ -35,22 +35,11 @@ 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) { + func send( + request: URLRequest, + delegate: URLSessionTaskDelegate?, + completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void + ) { let task = session.dataTask(with: request) { data, response, error in completion(httpClientResult(for: (data, response, error))) } @@ -76,17 +65,16 @@ private func basicHTTPAuthentication(username: String, password: String) -> Stri return "Basic \(credential)" } -/// As `URLSession` returns 3-values-tuple for request execution, this function applies consistency constraints and turns -/// it into only two possible states of `HTTPTransportResult`. -private func httpClientResult(for urlSessionTaskCompletion: (Data?, URLResponse?, Error?)) -> Result { - let (_, response, error) = urlSessionTaskCompletion +/// Returns a typed HTTP result from `URLSession`'s completion values. +private func httpClientResult(for urlSessionTaskCompletion: (Data?, URLResponse?, Error?)) -> Result<(HTTPURLResponse, Data?), Error> { + let (data, response, error) = urlSessionTaskCompletion if let error = error { return .failure(error) } if let httpResponse = response as? HTTPURLResponse { - return .success(httpResponse) + return .success((httpResponse, data)) } return .failure(URLSessionTransportInconsistencyException()) diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index 17327a3bba..55f37430f1 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -423,12 +423,13 @@ extension DatadogCore { site: configuration.site ) + let httpClient = configuration.httpClientFactory(configuration.proxyConfiguration) self.init( directory: directory, dateProvider: configuration.dateProvider, initialConsent: trackingConsent, performance: performance, - httpClient: configuration.httpClientFactory(configuration.proxyConfiguration), + httpClient: httpClient, encryption: configuration.encryption, contextProvider: DatadogContextProvider( site: configuration.site, @@ -461,13 +462,23 @@ extension DatadogCore { applicationVersion: applicationVersion, maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled, - isRunFromExtension: isRunFromExtension, - remoteConfiguration: configuration.remoteConfigurationID.map { ($0, configuration.site) } + isRunFromExtension: isRunFromExtension ) - synchronizer?.sync { [weak self] result in - if case .failure(let error) = result { - self?.telemetry.error("[RemoteConfig] Sync failed", error: error) + if let remoteConfigurationID = configuration.remoteConfigurationID { + let remoteConfigurationProvider = RemoteConfigurationProvider( + id: remoteConfigurationID, + site: configuration.site, + directory: directory.coreDirectory, + httpClient: httpClient, + telemetry: self.telemetry + ) + self.remoteConfigurationProvider = remoteConfigurationProvider + + remoteConfigurationProvider.sync { [weak self] result in + if case .failure(let error) = result { + self?.telemetry.error("[RemoteConfig] Sync failed", error: error) + } } } diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift index bf491ea793..38e24ee81f 100644 --- a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift @@ -62,15 +62,23 @@ class RemoteConfigurationTests: XCTestCase { super.tearDown() } - private func makeSynchronizer(id: String = "test-id") -> RemoteConfigurationSynchronizer { - RemoteConfigurationSynchronizer( + private func makeProvider( + id: String = "test-id", + telemetry: Telemetry = NOPTelemetry() + ) -> RemoteConfigurationProvider { + RemoteConfigurationProvider( id: id, site: .us1, directory: coreDir.coreDirectory, - httpClient: httpClient + httpClient: httpClient, + telemetry: telemetry ) } + private func remoteConfigurationData(applicationID: String = "application-id") -> Data { + Data("{\"rum\":{\"applicationId\":\"\(applicationID)\"}}".utf8) + } + // MARK: endpoint URL construction func testEndpointBuildsCorrectURL() { @@ -108,70 +116,104 @@ class RemoteConfigurationTests: XCTestCase { // MARK: Init func testInitCacheIsEmptyOnFirstLaunch() { - let rc = makeSynchronizer() + let rc = makeProvider() guard case .failure = rc.cache else { return XCTFail("Expected cache to be .failure on first launch") } } func testInitReadsCacheFromPreviousLaunch() throws { - let payload = Data("{\"session_sample_rate\":50}".utf8) + let payload = remoteConfigurationData(applicationID: "cached-application-id") 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) + let rc = makeProvider() + XCTAssertEqual(try rc.cache.get().rum?.applicationId, "cached-application-id") } func testInitCacheIsFailureWhenNoFileExists() { // No .json file on disk — cache must be .failure (first launch) - let rc = makeSynchronizer() + let rc = makeProvider() guard case .failure = rc.cache else { return XCTFail("Expected cache to be .failure when no file exists on disk") } } + func testInitCacheDiskReadFailureReportsTelemetry() throws { + try FileManager.default.createDirectory( + at: coreDir.coreDirectory.url.appendingPathComponent("test-id.json"), + withIntermediateDirectories: false + ) + let telemetry = TelemetryMock() + + let rc = makeProvider(telemetry: telemetry) + + XCTAssertTrue(telemetry.messages.firstError()?.message.hasPrefix("[RemoteConfig] Cache read failed") == true) + guard case .failure(.diskError) = rc.cache else { + return XCTFail("Expected cache to be .failure when cached file cannot be read") + } + } + + func testInitCacheIsFailureWhenFileCannotBeDecoded() throws { + try Data("this is not json".utf8).write( + to: coreDir.coreDirectory.url.appendingPathComponent("test-id.json"), + options: .atomic + ) + + let rc = makeProvider() + guard case .failure(.decodingError) = rc.cache else { + return XCTFail("Expected cache to be .failure when file cannot be decoded") + } + } + // MARK: sync() func testSyncReturnsSuccessAndPopulatesCache() { - let payload = Data("{\"session_sample_rate\":50}".utf8) + let payload = remoteConfigurationData(applicationID: "fetched-application-id") MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in - XCTAssertEqual(try? result.get(), payload) + XCTAssertEqual(try? result.get().rum?.applicationId, "fetched-application-id") expectation.fulfill() } wait(for: [expectation], timeout: 2) - XCTAssertEqual(try? rc.cache.get(), payload) + XCTAssertEqual(try? rc.cache.get().rum?.applicationId, "fetched-application-id") + XCTAssertEqual(try? Data(contentsOf: coreDir.coreDirectory.url.appendingPathComponent("test-id.json")), payload) } func testSyncPersistsCacheAcrossInstances() throws { - let payload = Data("{\"session_sample_rate\":50}".utf8) + let payload = remoteConfigurationData(applicationID: "persisted-application-id") MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { _ in expectation.fulfill() } wait(for: [expectation], timeout: 2) - XCTAssertEqual(try? makeSynchronizer().cache.get(), payload) + XCTAssertEqual(try? makeProvider().cache.get().rum?.applicationId, "persisted-application-id") } func testSyncNetworkErrorReturnsFailureAndLeavesCache() { - MockURLProtocol.requestHandler = { _ in throw URLError(.networkConnectionLost) } - let rc = makeSynchronizer() + let error = URLError(.networkConnectionLost) + let rc = RemoteConfigurationProvider( + id: "test-id", + site: .us1, + directory: coreDir.coreDirectory, + httpClient: HTTPClientMock(error: error) + ) let expectation = expectation(description: "sync completes") rc.sync { result in - guard case .failure = result else { + guard case .failure(.networkError(let receivedError as URLError)) = result else { return XCTFail("Expected failure on network error") } + XCTAssertEqual(receivedError.code, error.code) expectation.fulfill() } wait(for: [expectation], timeout: 2) @@ -182,14 +224,14 @@ class RemoteConfigurationTests: XCTestCase { } func testSyncNon2xxReturnsFailureAndPreservesCache() throws { - let existing = Data("{\"v\":1}".utf8) + let existing = remoteConfigurationData(applicationID: "existing-application-id") 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 rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in @@ -200,14 +242,19 @@ class RemoteConfigurationTests: XCTestCase { } wait(for: [expectation], timeout: 2) - XCTAssertEqual(try? makeSynchronizer().cache.get(), existing, "Existing cache must be preserved after non-2xx") + XCTAssertEqual( + try? makeProvider().cache.get().rum?.applicationId, + "existing-application-id", + "Existing cache must be preserved after non-2xx" + ) + XCTAssertEqual(try? Data(contentsOf: fileURL), existing, "Existing file 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 rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in @@ -223,21 +270,32 @@ class RemoteConfigurationTests: XCTestCase { } } - func testSyncNonJSONBodyIsSavedToCache() { - // JSON schema validation is deferred (TODO RUM-16387), so any non-empty 2xx - // body is accepted and written to cache as-is. + func testSyncInvalidJSONBodyReturnsFailureAndPreservesCache() throws { + let existing = remoteConfigurationData(applicationID: "existing-application-id") + let fileURL = coreDir.coreDirectory.url.appendingPathComponent("test-id.json") + try existing.write(to: fileURL, options: .atomic) + 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 rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in - XCTAssertEqual(try? result.get(), nonJSON) + guard case .failure(.decodingError) = result else { + return XCTFail("Expected decoding failure") + } expectation.fulfill() } wait(for: [expectation], timeout: 2) + + XCTAssertEqual( + try? rc.cache.get().rum?.applicationId, + "existing-application-id", + "Existing cache must be preserved after decoding error" + ) + XCTAssertEqual(try? Data(contentsOf: fileURL), existing, "Existing file must be preserved after decoding error") } func testSyncDiskWriteFailureReturnsFailure() { @@ -245,7 +303,7 @@ class RemoteConfigurationTests: XCTestCase { (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) } let missingDir = Directory(url: URL(fileURLWithPath: "/no/such/path/")) - let rc = RemoteConfigurationSynchronizer( + let rc = RemoteConfigurationProvider( id: "test-id", site: .us1, directory: missingDir, @@ -272,7 +330,7 @@ class RemoteConfigurationTests: XCTestCase { MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["ETag": "abc123"])!, Data("{}".utf8)) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { _ in expectation.fulfill() } @@ -294,7 +352,7 @@ class RemoteConfigurationTests: XCTestCase { MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { _ in expectation.fulfill() } wait(for: [expectation], timeout: 2) @@ -321,7 +379,7 @@ class RemoteConfigurationTests: XCTestCase { return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { _ in expectation.fulfill() } @@ -352,7 +410,7 @@ class RemoteConfigurationTests: XCTestCase { return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("{}".utf8)) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { _ in expectation.fulfill() } @@ -363,24 +421,24 @@ class RemoteConfigurationTests: XCTestCase { func test304ResponsePreservesCacheAndReportsSuccess() throws { // Given — pre-populate cache - let existing = Data("{\"v\":1}".utf8) + let existing = remoteConfigurationData(applicationID: "existing-application-id") 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: 304, httpVersion: nil, headerFields: nil)!, nil) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in - // 304 returns the current cache — .success with existing data - XCTAssertEqual(try? result.get(), existing, "304 must return existing cached data") + XCTAssertEqual(try? result.get().rum?.applicationId, "existing-application-id", "304 must return existing cached configuration") expectation.fulfill() } wait(for: [expectation], timeout: 2) - XCTAssertEqual(try? rc.cache.get(), existing, "Cache must be unchanged after 304") + XCTAssertEqual(try? rc.cache.get().rum?.applicationId, "existing-application-id", "Cache must be unchanged after 304") + XCTAssertEqual(try? Data(contentsOf: fileURL), existing, "File must be unchanged after 304") } func test304ResponseCallsCompletionEvenWhenCacheIsFailure() { @@ -389,7 +447,7 @@ class RemoteConfigurationTests: XCTestCase { MockURLProtocol.requestHandler = { request in (HTTPURLResponse(url: request.url!, statusCode: 304, httpVersion: nil, headerFields: nil)!, nil) } - let rc = makeSynchronizer() + let rc = makeProvider() let expectation = expectation(description: "sync completes") rc.sync { result in @@ -403,13 +461,13 @@ class RemoteConfigurationTests: XCTestCase { } func testDifferentIDsUseDifferentFiles() throws { - let payload1 = Data("{\"v\":1}".utf8) - let payload2 = Data("{\"v\":2}".utf8) + let payload1 = remoteConfigurationData(applicationID: "application-id-one") + let payload2 = remoteConfigurationData(applicationID: "application-id-two") MockURLProtocol.requestHandler = { _ in (HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload1) } - let rc1 = makeSynchronizer(id: "id-one") + let rc1 = makeProvider(id: "id-one") let exp1 = expectation(description: "sync 1 completes") rc1.sync { _ in exp1.fulfill() } wait(for: [exp1], timeout: 2) @@ -417,12 +475,12 @@ class RemoteConfigurationTests: XCTestCase { MockURLProtocol.requestHandler = { _ in (HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload2) } - let rc2 = makeSynchronizer(id: "id-two") + let rc2 = makeProvider(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) + XCTAssertEqual(try? makeProvider(id: "id-one").cache.get().rum?.applicationId, "application-id-one") + XCTAssertEqual(try? makeProvider(id: "id-two").cache.get().rum?.applicationId, "application-id-two") } } diff --git a/DatadogCore/Tests/Datadog/Core/Upload/URLSessionClientTests.swift b/DatadogCore/Tests/Datadog/Core/Upload/URLSessionClientTests.swift index 05d8b38efe..379ec6e926 100644 --- a/DatadogCore/Tests/Datadog/Core/Upload/URLSessionClientTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Upload/URLSessionClientTests.swift @@ -16,7 +16,7 @@ class URLSessionClientTests: XCTestCase { client.send(request: .mockAny()) { result in switch result { - case .success(let httpResponse): + case .success(let (httpResponse, _)): XCTAssertEqual(httpResponse.statusCode, 200) expectation.fulfill() case .failure: diff --git a/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift index a99401a692..7256074576 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift @@ -33,6 +33,56 @@ class DatadogCoreTests: XCTestCase { super.tearDown() } + func testGivenRemoteConfigurationProvider_whenReadingRemoteConfiguration_itReturnsCachedValue() throws { + // Given + try Data(#"{"rum":{"applicationId":"cache-application-id"}}"#.utf8).write( + to: temporaryCoreDirectory.coreDirectory.url.appendingPathComponent("test-id.json"), + options: .atomic + ) + let remoteConfigurationProvider = RemoteConfigurationProvider( + id: "test-id", + site: .us1, + directory: temporaryCoreDirectory.coreDirectory, + httpClient: HTTPClientMock() + ) + + let core = DatadogCore( + directory: temporaryCoreDirectory, + dateProvider: SystemDateProvider(), + initialConsent: .mockRandom(), + performance: .mockRandom(), + httpClient: HTTPClientMock(), + encryption: nil, + contextProvider: .mockAny(), + applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), + backgroundTasksEnabled: .mockAny(), + remoteConfigurationProvider: remoteConfigurationProvider + ) + + // Then + XCTAssertEqual(core.remoteConfiguration?.rum?.applicationId, "cache-application-id") + } + + func testGivenNoRemoteConfigurationProvider_whenReadingRemoteConfiguration_itReturnsNil() throws { + // Given + let core = DatadogCore( + directory: temporaryCoreDirectory, + dateProvider: SystemDateProvider(), + initialConsent: .mockRandom(), + performance: .mockRandom(), + httpClient: HTTPClientMock(), + encryption: nil, + contextProvider: .mockAny(), + applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), + backgroundTasksEnabled: .mockAny() + ) + + // Then + XCTAssertNil(core.remoteConfiguration) + } + func testWhenWritingEventsWithDifferentTrackingConsent_itOnlyUploadsAuthorizedEvents() throws { // Given let core = DatadogCore( diff --git a/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index a3fe9d7c4e..85dba93f22 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.synchronizer) + XCTAssertNil(core.remoteConfigurationProvider) } func testGivenRemoteConfigurationID_remoteConfigurationIsCreated() throws { @@ -546,7 +546,7 @@ class DatadogTests: XCTestCase { // Then let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) - XCTAssertNotNil(core.synchronizer) + XCTAssertNotNil(core.remoteConfigurationProvider) } func testCustomSDKInstance() throws { diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index e810f07b61..6cc6b837e8 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -54,7 +54,7 @@ extension DatadogSite { } /// The base CDN URL for fetching remote configuration documents. - /// The full URL (with API version path and ID) is constructed by `RemoteConfigurationSynchronizer`. + /// The full URL (with API version path and ID) is constructed by DatadogCore. public var remoteConfigurationEndpoint: URL { // swiftlint:disable:next force_unwrapping URL(string: "https://sdk-configuration.\(host)/")! diff --git a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift index 18110713d1..b9c4c7c872 100644 --- a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift +++ b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift @@ -13,18 +13,18 @@ public class HTTPClientMock: HTTPClient { /// Keeps track of sent requests. private var requests: [URLRequest] = [] /// Closure providing the result for each request. - private let result: (URLRequest) -> Result + private let result: (URLRequest) -> Result<(HTTPURLResponse, Data?), Error> /// Initializes the mock client with a result closure. /// - Parameter result: Closure providing the completion result for each incoming request (default is a successful HTTP response with `202` code). - public init(result: @escaping ((URLRequest) -> Result) = { _ in .success(.mockResponseWith(statusCode: 202)) }) { + public init(result: @escaping ((URLRequest) -> Result<(HTTPURLResponse, Data?), Error>) = { _ in .success((.mockResponseWith(statusCode: 202), nil)) }) { self.result = result } /// Convenience initializer for creating a mock client with a predefined response. /// - Parameter response: `HTTPURLResponse` to be used as completion for all incoming requests. - public convenience init(response: HTTPURLResponse) { - self.init(result: { _ in .success(response) }) + public convenience init(response: HTTPURLResponse, data: Data? = nil) { + self.init(result: { _ in .success((response, data)) }) } /// Convenience initializer for creating a mock client with a predefined response code. @@ -41,7 +41,11 @@ public class HTTPClientMock: HTTPClient { // MARK: - HTTPClient conformance - public func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) { + public func send( + request: URLRequest, + delegate: URLSessionTaskDelegate?, + completion: @escaping (Result<(HTTPURLResponse, Data?), any Error>) -> Void + ) { queue.async { completion(self.result(request)) self.requests.append(request)