Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Datadog/Datadog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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 */; };
10C736962FD2E43F00FCD545 /* RemoteConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C736952FD2E43800FCD545 /* RemoteConfigurationProvider.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 */; };
Expand Down Expand Up @@ -1775,6 +1776,7 @@
09A9369C2F0EB989000B6379 /* SpanContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpanContext.swift; sourceTree = "<group>"; };
0D2A20EFB1D82D0B8AC781F9 /* HeatmapMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapMocks.swift; sourceTree = "<group>"; };
1019DFD82FB1BB23006599B4 /* DatadogSiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogSiteTests.swift; sourceTree = "<group>"; };
10C736952FD2E43800FCD545 /* RemoteConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationProvider.swift; sourceTree = "<group>"; };
10E906292FBDB818002D5F45 /* RemoteConfigurationSynchronizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationSynchronizer.swift; sourceTree = "<group>"; };
10E9062B2FBDBA3C002D5F45 /* RemoteConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigurationTests.swift; sourceTree = "<group>"; };
11014EACF1FB9927DAD57822 /* EvaluationMocks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvaluationMocks.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6063,6 +6065,7 @@
D23039B2298D5235001A1FA3 /* Context */ = {
isa = PBXGroup;
children = (
10C736952FD2E43800FCD545 /* RemoteConfigurationProvider.swift */,
2671348F2D688D230048CB54 /* AccountInfo.swift */,
E2AA55E62C32C6D9002FEF28 /* ApplicationNotifications.swift */,
D23039B3298D5235001A1FA3 /* AppState.swift */,
Expand Down Expand Up @@ -8533,6 +8536,7 @@
D2D3199A29E98D970004F169 /* DefaultJSONEncoder.swift in Sources */,
6128F56A2BA2237300D35B08 /* DataStore.swift in Sources */,
3C3EF2B02C1AEBAB009E9E57 /* LaunchReport.swift in Sources */,
10C736962FD2E43F00FCD545 /* RemoteConfigurationProvider.swift in Sources */,
6167E7002B81EF7500C3CA2D /* BacktraceReportingFeature.swift in Sources */,
D2EBEE2729BA160F00B15732 /* B3HTTPHeadersWriter.swift in Sources */,
D2D9A9DD2DBFD507005DB31D /* RUMPayloadMessages.swift in Sources */,
Expand Down
21 changes: 17 additions & 4 deletions DatadogCore/Sources/Core/DatadogCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,24 @@ internal final class DatadogCore {
/// The message-bus instance.
let bus = MessageBus()

/// The remote configuration provider.
let remoteConfigurationProvider: RemoteConfigurationProvider

/// Owns the remote configuration cache and fetch lifecycle.
/// `nil` when `remoteConfiguration` was not set at init.
internal let synchronizer: RemoteConfigurationSynchronizer?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets remove the RemoteConfigurationSynchronizer :)


/// The last successfully fetched remote configuration, if any.
var remoteConfiguration: RemoteConfiguration? {
if let remoteConfiguration = remoteConfigurationProvider.remoteConfiguration {
return remoteConfiguration
}
guard let data = try? synchronizer?.cache.get() else {
return nil
}
return try? JSONDecoder().decode(RemoteConfiguration.self, from: data)
}

/// Registry for Features.
@ReadWriteLock
private(set) var stores: [String: (storage: FeatureStorage, upload: FeatureUpload)] = [:]
Expand All @@ -79,10 +93,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:
Expand All @@ -94,6 +104,7 @@ internal final class DatadogCore {
/// - encryption: The on-disk data encryption.
/// - contextProvider: The core context provider.
/// - applicationVersion: The application version.
/// - remoteConfigurationProvider: The remote configuration provider.
/// - remoteConfiguration: The remote configuration ID and site; `nil` when not configured.
init(
directory: CoreDirectory,
Expand All @@ -107,10 +118,12 @@ internal final class DatadogCore {
maxBatchesPerUpload: Int,
backgroundTasksEnabled: Bool,
isRunFromExtension: Bool = false,
remoteConfigurationProvider: RemoteConfigurationProvider = DefaultRemoteConfigurationProvider(),
remoteConfiguration: (id: String, site: DatadogSite)? = nil
) {
self.directory = directory
self.dateProvider = dateProvider
self.remoteConfigurationProvider = remoteConfigurationProvider
self.performance = performance
self.httpClient = httpClient
self.encryption = encryption
Expand Down
26 changes: 15 additions & 11 deletions DatadogCore/Sources/Core/RemoteConfigurationSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal final class RemoteConfigurationSynchronizer {
/// `.success(data)` — data is available.
/// `.failure` — no cache yet, or a read/write error.
@ReadWriteLock
private(set) var cache: Result<Data, Error>
private(set) var cache: Result<Data, RemoteConfigurationError>

init(id: String, site: DatadogSite, directory: Directory, httpClient: HTTPClient) {
self.id = id
Expand All @@ -39,11 +39,11 @@ internal final class RemoteConfigurationSynchronizer {

// MARK: - Private

private static func readCache(id: String, from directory: Directory) -> Result<Data, Error> {
private static func readCache(id: String, from directory: Directory) -> Result<Data, RemoteConfigurationError> {
do {
return .success(try directory.file(named: "\(id).json").read())
} catch {
return .failure(error)
return .failure(.diskError)
}
}

Expand All @@ -52,7 +52,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<Data, Error>) -> Void) {
func sync(_ completionHandler: @escaping (Result<Data, RemoteConfigurationError>) -> 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.
Expand All @@ -70,7 +70,7 @@ internal final class RemoteConfigurationSynchronizer {
httpClient.fetch(request: request) { 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
Expand All @@ -81,21 +81,21 @@ internal final class RemoteConfigurationSynchronizer {

// 2. Non-2xx HTTP status
guard (200..<300).contains(http.statusCode) else {
completionHandler(.failure(RemoteConfigurationError.httpError(http.statusCode)))
let error = RemoteConfigurationError.httpError(http.statusCode)
completionHandler(.failure(error))
return
}

// 3. Empty body
guard !data.isEmpty else {
completionHandler(.failure(RemoteConfigurationError.emptyBody))
completionHandler(.failure(.emptyBody))
return
}

// TODO RUM-16387: Validate the schema before saving

// 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.
// Note: schema validation before write is deferred to RUM-16387.
do {
try File(url: self.directory.url.appendingPathComponent("\(self.id).json")).write(data: data)
self.cache = .success(data)
Expand All @@ -116,21 +116,25 @@ internal final class RemoteConfigurationSynchronizer {

completionHandler(.success(data))
} 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 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 .diskError: return "Remote configuration disk read/write failed"
}
}
}
1 change: 1 addition & 0 deletions DatadogCore/Sources/Datadog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ extension DatadogCore {
maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload,
backgroundTasksEnabled: configuration.backgroundTasksEnabled,
isRunFromExtension: isRunFromExtension,
remoteConfigurationProvider: configuration.remoteConfigurationProvider,
remoteConfiguration: configuration.remoteConfigurationID.map { ($0, configuration.site) }
)

Expand Down
3 changes: 3 additions & 0 deletions DatadogCore/Sources/DatadogConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,5 +247,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()

/// Default remote configuration provider.
internal var remoteConfigurationProvider: RemoteConfigurationProvider = DefaultRemoteConfigurationProvider()
}
}
31 changes: 28 additions & 3 deletions DatadogCore/Tests/Datadog/Core/RemoteConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ private func mockSession() -> URLSession {
return URLSession(configuration: config)
}

private final class FetchHTTPClientMock: HTTPClient {
let result: Result<(HTTPURLResponse, Data), Error>

init(result: Result<(HTTPURLResponse, Data), Error>) {
self.result = result
}

func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
completion(.failure(URLSessionTransportInconsistencyException()))
}

func fetch(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data), Error>) -> Void) {
completion(result)
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this in previous PR, but I think we should rely on a single send method in the protocol HTTPClient with the following signature:

func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void

This clarify the usage and avoid having to provide a default implementation in the protocol extension.


// MARK: - Tests

class RemoteConfigurationTests: XCTestCase {
Expand Down Expand Up @@ -164,14 +180,20 @@ class RemoteConfigurationTests: XCTestCase {
}

func testSyncNetworkErrorReturnsFailureAndLeavesCache() {
MockURLProtocol.requestHandler = { _ in throw URLError(.networkConnectionLost) }
let rc = makeSynchronizer()
let error = URLError(.networkConnectionLost)
let rc = RemoteConfigurationSynchronizer(
id: "test-id",
site: .us1,
directory: coreDir.coreDirectory,
httpClient: FetchHTTPClientMock(result: .failure(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)
Expand Down Expand Up @@ -238,6 +260,9 @@ class RemoteConfigurationTests: XCTestCase {
expectation.fulfill()
}
wait(for: [expectation], timeout: 2)

XCTAssertEqual(try? rc.cache.get(), nonJSON)
XCTAssertEqual(try? Data(contentsOf: coreDir.coreDirectory.url.appendingPathComponent("test-id.json")), nonJSON)
}

func testSyncDiskWriteFailureReturnsFailure() {
Expand Down
59 changes: 59 additions & 0 deletions DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ private struct FeatureMock: DatadogRemoteFeature {
var performanceOverride: PerformancePresetOverride? = nil
}

private struct RemoteConfigurationProviderMock: RemoteConfigurationProvider {
let remoteConfiguration: RemoteConfiguration?
}

class DatadogCoreTests: XCTestCase {
override func setUp() {
super.setUp()
Expand All @@ -33,6 +37,61 @@ class DatadogCoreTests: XCTestCase {
super.tearDown()
}

func testGivenRemoteConfigurationProvider_whenReadingRemoteConfiguration_itReturnsProviderValue() throws {
// Given
let remoteConfiguration = RemoteConfiguration(
rum: .init(applicationId: "provider-application-id")
)
try Data(#"{"rum":{"applicationId":"cache-application-id"}}"#.utf8).write(
to: temporaryCoreDirectory.coreDirectory.url.appendingPathComponent("test-id.json"),
options: .atomic
)

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: RemoteConfigurationProviderMock(remoteConfiguration: remoteConfiguration),
remoteConfiguration: (id: "test-id", site: .us1)
)

// Then
XCTAssertEqual(core.remoteConfiguration?.rum?.applicationId, "provider-application-id")
}

func testGivenNoRemoteConfigurationProviderValue_whenReadingRemoteConfiguration_itReturnsSynchronizerCache() throws {
// Given
try Data(#"{"rum":{"applicationId":"cache-application-id"}}"#.utf8).write(
to: temporaryCoreDirectory.coreDirectory.url.appendingPathComponent("test-id.json"),
options: .atomic
)

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: RemoteConfigurationProviderMock(remoteConfiguration: nil),
remoteConfiguration: (id: "test-id", site: .us1)
)

// Then
XCTAssertEqual(core.remoteConfiguration?.rum?.applicationId, "cache-application-id")
}

func testWhenWritingEventsWithDifferentTrackingConsent_itOnlyUploadsAuthorizedEvents() throws {
// Given
let core = DatadogCore(
Expand Down
20 changes: 20 additions & 0 deletions DatadogInternal/Sources/Context/RemoteConfigurationProvider.swift

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This protocol belongs to theDatadogCore module as it doesn't need to be exposed to feature modules.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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

/// Provides the last successfully fetched remote configuration.
public protocol RemoteConfigurationProvider {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the provider out of the public API

This adds a new public protocol in DatadogInternal (and DatadogCoreProtocol now publicly inherits it), but the repo-level AGENTS.md says “Do NOT introduce new public API without RFC review.” Since this provider is only used as an internal/test injection seam and the API surface/ObjC bridge are not updated in this commit, it leaks a stable SDK API that would need review and compatibility guarantees; make it SPI/internal or route it through the public API process before exposing it.

Useful? React with 👍 / 👎.

/// The last successfully fetched and decoded remote configuration, if any.
var remoteConfiguration: RemoteConfiguration? { get }
}

/// Default implementation that returns no remote configuration.
public struct DefaultRemoteConfigurationProvider: RemoteConfigurationProvider {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public struct DefaultRemoteConfigurationProvider: RemoteConfigurationProvider {
public struct NOPRemoteConfigurationProvider: RemoteConfigurationProvider {

public init() { }

public var remoteConfiguration: RemoteConfiguration? { nil }
}