diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c96600fd..db7395eb8f 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 [#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][] @@ -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 +[#2919]: https://github.com/DataDog/dd-sdk-ios/pull/2919 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index f29b9dfe55..aacb06497a 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -401,8 +404,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 */; }; @@ -678,6 +679,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 */; }; @@ -792,6 +794,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 */; }; @@ -1770,6 +1773,9 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -1939,6 +1945,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 = ""; }; @@ -2103,8 +2110,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 = ""; }; @@ -2511,6 +2516,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 = ""; }; @@ -4456,6 +4462,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( + 10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -4582,6 +4589,7 @@ 61133C212423990D00786299 /* Core */ = { isa = PBXGroup; children = ( + 10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */, 61DA8CAB2861C3720074A606 /* DirectoriesTests.swift */, 61EF78C0257F842000EDCCB3 /* FeatureTests.swift */, 61345612244756E300E7DA6B /* PerformancePresetTests.swift */, @@ -6621,6 +6629,7 @@ D2DA239C298D58F300C6C7E6 /* Context */ = { isa = PBXGroup; children = ( + 1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */, D2DA239D298D58F300C6C7E6 /* AppStateHistoryTests.swift */, D2DA239E298D58F300C6C7E6 /* DeviceInfoTests.swift */, 6174D6152BFDF29B00EC7469 /* BundleTypeTests.swift */, @@ -7902,6 +7911,7 @@ 617699182A860D9D0030022B /* HTTPClient.swift in Sources */, D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */, 61133BD42423979B00786299 /* FileReader.swift in Sources */, + 10E9062A2FBDB81A002D5F45 /* RemoteConfigurationSynchronizer.swift in Sources */, D29294E0291D5ED100F8EFF9 /* ApplicationVersionPublisher.swift in Sources */, 61D3E0D9277B23F1008BE766 /* KronosNTPProtocol.swift in Sources */, 61D3E0DA277B23F1008BE766 /* KronosTimeFreeze.swift in Sources */, @@ -7964,6 +7974,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 */, @@ -8988,6 +8999,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..730da4db4b 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,6 +55,10 @@ 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? + /// Registry for Features. @ReadWriteLock private(set) var stores: [String: (storage: FeatureStorage, upload: FeatureUpload)] = [:] @@ -86,6 +90,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. init( directory: CoreDirectory, dateProvider: DateProvider, @@ -97,7 +102,8 @@ internal final class DatadogCore { applicationVersion: String, maxBatchesPerUpload: Int, backgroundTasksEnabled: Bool, - isRunFromExtension: Bool = false + isRunFromExtension: Bool = false, + remoteConfiguration: (id: String, site: DatadogSite)? = nil ) { self.directory = directory self.dateProvider = dateProvider @@ -114,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 = 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 new file mode 100644 index 0000000000..b4b51a3405 --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift @@ -0,0 +1,102 @@ +/* + * 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` during initialization when a `remoteConfigurationID` is provided. +/// Call `sync(_:)` to fire a CDN fetch and update the 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 { + 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. + self._cache = ReadWriteLock(wrappedValue: Self.readCache(id: id, from: directory)) + } + + // MARK: - Private + + private static func readCache(id: String, from directory: Directory) -> Result { + do { + return .success(try directory.file(named: "\(id).json").read()) + } catch { + return .failure(error) + } + } + + // MARK: - Internal + + /// 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) { + // 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 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)) + } + } + } + } +} + +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 1c054cd7c6..17327a3bba 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 @@ -297,6 +298,7 @@ public enum Datadog { CITestIntegration.active?.startIntegration() CoreRegistry.register(core, named: instanceName) + deleteV1Folders(in: core) DD.logger = InternalLogger( @@ -415,12 +417,14 @@ extension DatadogCore { ) let isRunFromExtension = bundleType == .iOSAppExtension + let directory = try CoreDirectory( + in: configuration.systemDirectory(), + instanceName: instanceName, + site: configuration.site + ) + self.init( - directory: try CoreDirectory( - in: configuration.systemDirectory(), - instanceName: instanceName, - site: configuration.site - ), + directory: directory, dateProvider: configuration.dateProvider, initialConsent: trackingConsent, performance: performance, @@ -457,9 +461,16 @@ extension DatadogCore { applicationVersion: applicationVersion, maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled, - isRunFromExtension: isRunFromExtension + isRunFromExtension: isRunFromExtension, + remoteConfiguration: configuration.remoteConfigurationID.map { ($0, configuration.site) } ) + synchronizer?.sync { [weak self] result in + if case .failure(let error) = result { + self?.telemetry.error("[RemoteConfig] Sync failed", error: error) + } + } + telemetry.configuration( backgroundTasksEnabled: configuration.backgroundTasksEnabled, batchProcessingLevel: Int64(exactly: configuration.batchProcessingLevel.maxBatchesPerUpload), 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 5455d8c573..1f967deeda 100644 --- a/DatadogCore/Sources/DatadogConfiguration.swift +++ b/DatadogCore/Sources/DatadogConfiguration.swift @@ -123,6 +123,18 @@ 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. + /// + /// 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. /// /// - Parameters: @@ -169,6 +181,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 +199,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 +215,7 @@ extension Datadog { self.serverDateProvider = serverDateProvider ?? DatadogNTPDateProvider() self.batchProcessingLevel = batchProcessingLevel self.backgroundTasksEnabled = backgroundTasksEnabled + self.remoteConfigurationID = remoteConfigurationID } // MARK: - Internal diff --git a/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift new file mode 100644 index 0000000000..1eb9230fc4 --- /dev/null +++ b/DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift @@ -0,0 +1,296 @@ +/* + * 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 { + 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 httpClient: URLSessionClient! // swiftlint:disable:this implicitly_unwrapped_optional + + override func setUp() { + super.setUp() + coreDir = temporaryUniqueCoreDirectory() + coreDir.create() + httpClient = URLSessionClient(session: mockSession()) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + httpClient.session.invalidateAndCancel() + httpClient = nil + coreDir.delete() + super.tearDown() + } + + 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 = 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 = 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 = 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 = 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 testInitCacheIsEmptyOnFirstLaunch() { + let rc = makeSynchronizer() + 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 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 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 = makeSynchronizer() + guard case .failure = rc.cache else { + return XCTFail("Expected cache to be .failure when file is unreadable") + } + } + + // 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)!, payload) + } + 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 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 = 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) + + 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) + + 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/DatadogCore/Tests/Datadog/DatadogTests.swift b/DatadogCore/Tests/Datadog/DatadogTests.swift index 21fb7d3fea..a3fe9d7c4e 100644 --- a/DatadogCore/Tests/Datadog/DatadogTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogTests.swift @@ -12,6 +12,8 @@ import TestUtilities @testable import DatadogTrace @testable import DatadogCore +// 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") @@ -521,6 +523,32 @@ class DatadogTests: XCTestCase { XCTAssertThrowsError(try cache.subdirectory(path: "com.datadoghq.rum")) } + // MARK: Remote Configuration + + func testGivenNoRemoteConfigurationID_cacheIsNotCreated() throws { + // When + Datadog.initialize(with: defaultConfig, trackingConsent: .granted) + defer { Datadog.flushAndDeinitialize() } + + // Then + let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) + XCTAssertNil(core.synchronizer) + } + + func testGivenRemoteConfigurationID_remoteConfigurationIsCreated() throws { + // Given + var config = defaultConfig + config.remoteConfigurationID = "test-id" + + // When + Datadog.initialize(with: config, trackingConsent: .granted) + defer { Datadog.flushAndDeinitialize() } + + // Then + let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) + XCTAssertNotNil(core.synchronizer) + } + func testCustomSDKInstance() throws { // When Datadog.initialize( 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 22b605a836..e810f07b61 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -34,18 +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" } } + + 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 new file mode 100644 index 0000000000..8fbceb2c44 --- /dev/null +++ b/DatadogInternal/Tests/Context/DatadogSiteTests.swift @@ -0,0 +1,66 @@ +/* + * 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: - host per site + + 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 testUS3RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us3.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us3-datadoghq.com/") + } + + func testUS5RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us5.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us5-datadoghq.com/") + } + + func testEU1RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.eu1.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-datadoghq.eu/") + } + + func testAP1RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.ap1.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ap1-datadoghq.com/") + } + + func testAP2RemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.ap2.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ap2-datadoghq.com/") + } + + func testUS1FedRemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us1_fed.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-ddog-gov.com/") + } + + func testUS2FedRemoteConfigurationEndpoint() { + XCTAssertEqual(DatadogSite.us2_fed.remoteConfigurationEndpoint.absoluteString, "https://sdk-configuration.browser-intake-us2-ddog-gov.com/") + } +} 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