From 11b5417667356d9fd423f90b3423bd4f21a4cf38 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 08:33:12 -0700 Subject: [PATCH 1/9] wip(core): add a body-returning fetch and the shared remote-sampling types Groundwork for taking the sampling rates from the console. The core's HTTP client only surfaced the status code, which is all an upload needs; asking the backend for something needs what came back, and must go through the same client so it honours the proxy the customer configured. Not wired to anything yet. --- .../Sources/Core/Upload/HTTPClient.swift | 11 ++++ .../Core/Upload/URLSessionClient.swift | 15 ++++++ DatadogInternal/Sources/RemoteSampling.swift | 54 +++++++++++++++++++ .../Mocks/DatadogCore/HTTPClientMock.swift | 8 +++ 4 files changed, 88 insertions(+) create mode 100644 DatadogInternal/Sources/RemoteSampling.swift diff --git a/DatadogCore/Sources/Core/Upload/HTTPClient.swift b/DatadogCore/Sources/Core/Upload/HTTPClient.swift index f881608215..d0d26cb9eb 100644 --- a/DatadogCore/Sources/Core/Upload/HTTPClient.swift +++ b/DatadogCore/Sources/Core/Upload/HTTPClient.swift @@ -14,6 +14,17 @@ 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) + + /// Sends the provided request and hands back the response together with its body. + /// + /// Uploading only needs to know how the backend replied, so `send(request:)` discards the body. + /// Asking the backend for something — the sampling configuration the console sets — needs what + /// came back, and must go through the same client so it honours the proxy the customer + /// configured rather than quietly bypassing it. + /// - Parameters: + /// - request: The request to be sent. + /// - completion: A closure that receives a Result containing either the response and its body, or an Error. + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) } extension HTTPClient { diff --git a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift index 924ecb8535..3f7af60127 100644 --- a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift +++ b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift @@ -44,6 +44,21 @@ internal class URLSessionClient: HTTPClient { } task.resume() } + + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(URLSessionTransportInconsistencyException())) + return + } + completion(.success((response: httpResponse, body: data ?? Data()))) + } + task.resume() + } } /// An error returned if `URLSession` response state is inconsistent (like no data, no response and no error). diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift new file mode 100644 index 0000000000..6cf8d6281a --- /dev/null +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -0,0 +1,54 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// Where to ask for the sampling rates set in the Flashcat console. +/// +/// RUM publishes this when the app opts in, because the address depends on RUM's own endpoint +/// configuration, which the core does not otherwise know. Its presence is also what tells the core +/// there is anything to ask for: with no source published, nothing is fetched and nothing changes. +public struct RemoteSamplingSource: AdditionalContext, Equatable { + public static let key = "remote-sampling-source" + + /// The full configuration URL, including the query the server matches rules on. + public let configurationURL: URL + + public init(configurationURL: URL) { + self.configurationURL = configurationURL + } +} + +/// The sampling rates the console last provided. +/// +/// The core is the only writer; RUM and Session Replay read it to decide whether to keep a session +/// and whether to record it. A rate is absent — never zero — when the console did not set it, and +/// the feature then keeps the value the app was initialised with. Reporting a zero we invented +/// would silently stop collection nobody asked to stop. +public struct RemoteSamplingRates: AdditionalContext, Equatable { + public static let key = "remote-sampling-rates" + + public let sessionSampleRate: SampleRate? + public let sessionReplaySampleRate: SampleRate? + + public init(sessionSampleRate: SampleRate?, sessionReplaySampleRate: SampleRate?) { + self.sessionSampleRate = sessionSampleRate + self.sessionReplaySampleRate = sessionReplaySampleRate + } + + public var isEmpty: Bool { sessionSampleRate == nil && sessionReplaySampleRate == nil } +} + +/// Sent by the core when the console asked for a change to take effect immediately and the rates +/// this app will now draw with really changed. +/// +/// RUM answers it by ending the running session so a new one starts under the new rates. Ending and +/// restarting is deliberate: a session that was not being collected has no id and no history, so +/// flipping its decision in place would invent a session that appears to begin mid-use, and a +/// collected session flipped off would simply stop, looking like it ended early. +public struct RemoteSamplingChangedMessage { + public init() {} +} diff --git a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift index 18110713d1..524f43a1e8 100644 --- a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift +++ b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift @@ -14,6 +14,8 @@ public class HTTPClientMock: HTTPClient { private var requests: [URLRequest] = [] /// Closure providing the result for each request. private let result: (URLRequest) -> Result + /// Body handed back by `fetch(request:)`, for requests that read a response rather than upload. + public var fetchBody: Data = Data() /// 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). @@ -48,6 +50,12 @@ public class HTTPClientMock: HTTPClient { } } + public func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + send(request: request, delegate: nil) { result in + completion(result.map { (response: $0, body: self.fetchBody) }) + } + } + // MARK: - Tracked requests retrieval /// Retrieves the tracked requests. From a6d58aade2988be108fc791a772f1fe247fa556c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 04:20:41 -0700 Subject: [PATCH 2/9] feat(rum): let the console set the session sample rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The application owner can change how much traffic RUM keeps without the customer shipping a new release. Off by default: without `remoteConfigurationEnabled` the SDK makes no extra request and behaves exactly as before. The core fetches the configuration when RUM publishes its source — at SDK init and at every session creation — and stores it for the next draw. A change therefore only affects sessions created after it arrives, so a visitor is never dropped halfway through. A failed fetch keeps the stored values rather than falling back, so a bad minute at the endpoint cannot swing a fleet back to its built-in rates. Each session records the rate it was actually drawn under and the configuration version it came from, and its view events report both, so server-side extrapolation lines up with the draw that kept the session rather than with whatever has arrived since. The console's custom values ride along untouched and are handed to the host application through `remoteConfig()`; the platform delivers them, their meaning belongs to the application. Only the session sample rate is delivered. Session Replay and distributed tracing are not configured from here on this platform. Evaluating the context closure on the caller's thread, which an earlier draft of this change did to inspect the published value, races with the scope mutations that happen on the context queue — ThreadSanitizer catches it in RUMApplicationScope. The closure is evaluated on the context queue as before and the value inspected there. --- Datadog/Datadog.xcodeproj/project.pbxproj | 70 ++++ DatadogCore/Sources/Core/DatadogCore.swift | 31 +- .../RemoteSamplingController.swift | 206 ++++++++++ .../RemoteSamplingSnapshot.swift | 275 +++++++++++++ .../RemoteSamplingControllerTests.swift | 365 ++++++++++++++++++ .../RemoteSamplingSnapshotTests.swift | 201 ++++++++++ .../Sources/Models/RUM/RUMDataModels.swift | 12 + DatadogInternal/Sources/RemoteSampling.swift | 36 +- DatadogRUM/Sources/Feature/RUMFeature.swift | 5 +- .../Integrations/RemoteSamplingReceiver.swift | 36 ++ DatadogRUM/Sources/RUMConfiguration.swift | 13 + .../Sources/RUMContext/RUMContext.swift | 4 + DatadogRUM/Sources/RUMMonitor/Monitor.swift | 20 +- .../Scopes/RUMDrawnConfiguration.swift | 51 +++ .../Scopes/RUMScopeDependencies.swift | 12 +- .../RUMMonitor/Scopes/RUMSessionScope.swift | 24 +- .../RUMMonitor/Scopes/RUMViewScope.swift | 10 +- DatadogRUM/Sources/RUMMonitorProtocol.swift | 16 + .../RemoteSamplingReceiverTests.swift | 73 ++++ .../Scopes/RUMDrawnConfigurationTests.swift | 239 ++++++++++++ .../Mocks/DatadogCore/HTTPClientMock.swift | 2 +- .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 8 +- 22 files changed, 1694 insertions(+), 15 deletions(-) create mode 100644 DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift create mode 100644 DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift create mode 100644 DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift create mode 100644 DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift create mode 100644 DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift create mode 100644 DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift create mode 100644 DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift create mode 100644 DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 847a829e13..2d9dedb7c3 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1152,6 +1152,10 @@ 96F25A822CC7EA4400459567 /* SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */; }; 96F25A832CC7EA4400459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */; }; 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; + 282EF63D069B9056BD53AF91 /* RemoteSamplingSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */; }; + 12D14979916A036A2CC36DBE /* RemoteSamplingSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */; }; + 0E81AE3C56D66A15CCF3B697 /* RemoteSamplingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */; }; + 5CEEC145E560188306A44870 /* RemoteSamplingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */; }; 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 96F70D452DD793C400D3736B /* RUMAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F70D442DD793C400D3736B /* RUMAction.swift */; }; 96F70D462DD793C400D3736B /* RUMAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F70D442DD793C400D3736B /* RUMAction.swift */; }; @@ -1297,6 +1301,8 @@ D21C26D228A64599005DD405 /* MessageBusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26D028A64599005DD405 /* MessageBusTests.swift */; }; D22442C52CA301DA002E71E4 /* UIColor+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */; }; D224430429E9588100274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; + 02A7E6777D3C038F8CF2AC39 /* RemoteSamplingReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */; }; + 8BB6657E07CFDC5C252B17E1 /* RemoteSamplingReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */; }; D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; D224430629E95C2C00274EC7 /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA429E072D7004D0AE8 /* MessageBus.swift */; }; D224430729E95C2E00274EC7 /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA429E072D7004D0AE8 /* MessageBus.swift */; }; @@ -1485,6 +1491,8 @@ D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9973F0268DF69500D8059B /* VitalInfoSampler.swift */; }; D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C21124C5951400C0321C /* RUMViewScope.swift */; }; D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; + 20EDC40A8B14129FB3E4928B /* RUMDrawnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */; }; + 32989812C6103BA00321C225 /* RUMDrawnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */; }; D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */; }; D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */; }; D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCA238271D896E0020286F /* SwiftUIExtensions.swift */; }; @@ -1497,6 +1505,8 @@ D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD824C7269500589570 /* RUMUUIDGenerator.swift */; }; D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; + DE687A414FCC3A4D328ABC7E /* RUMDrawnConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */; }; + A36A655187BCCE5680EC5984 /* RUMDrawnConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */; }; D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FD9FCE28534EBD00214BD9 /* RUMDeviceInfoTests.swift */; }; D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */; }; @@ -1508,6 +1518,8 @@ D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */; }; D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529AD25E3E429004F740E /* ValuePublisherTests.swift */; }; D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; + B1A879D1C2D68AD9AA4BB1AE /* RemoteSamplingReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */; }; + 224D398714B6DD75DD8044E9 /* RemoteSamplingReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */; }; D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */; }; D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; @@ -1808,6 +1820,10 @@ D2B3F0442823EE8400C2B5EE /* TLVBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */; }; D2B3F0452823EE8400C2B5EE /* TLVBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */; }; D2B3F04D282A85FD00C2B5EE /* DatadogCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */; }; + FF1587B3CF5C9AC1CA800DAF /* RemoteSamplingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */; }; + 3B8115C1224250F4CD369D23 /* RemoteSamplingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */; }; + F7143B3DEDFBE78C328A89B9 /* RemoteSamplingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */; }; + 6DF401C28146329A1EBE3761 /* RemoteSamplingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */; }; D2B3F04E282A85FD00C2B5EE /* DatadogCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */; }; D2B3F052282E827700C2B5EE /* DDHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */; }; D2B3F053282E827B00C2B5EE /* DDHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */; }; @@ -2080,6 +2096,8 @@ D2DC4BF627F484AA00E4FB96 /* DataEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */; }; D2DC4BF727F484AA00E4FB96 /* DataEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */; }; D2DE63532A30A7CA00441A54 /* CoreRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */; }; + 70B102EEA2DD2A8186D31B62 /* RemoteSampling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 784964956777BCDE3B3162A7 /* RemoteSampling.swift */; }; + 3CBF72DC4C4F98B6205D5576 /* RemoteSampling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 784964956777BCDE3B3162A7 /* RemoteSampling.swift */; }; D2DE63542A30A7CA00441A54 /* CoreRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */; }; D2E6E8FB2D8039BB00FF1398 /* BenchmarkURLSessionTaskDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */; }; D2E6E8FC2D8039BB00FF1398 /* BenchmarkURLSessionTaskDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */; }; @@ -3237,6 +3255,8 @@ 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMUser.swift; sourceTree = ""; }; 614B0A4E24EBDC6B00A2A780 /* RUMConnectivityInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMConnectivityInfoProvider.swift; sourceTree = ""; }; 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatadogCoreTests.swift; sourceTree = ""; }; + 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingSnapshotTests.swift; sourceTree = ""; }; + C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingControllerTests.swift; sourceTree = ""; }; 614B78EC296D7B63009C6B92 /* LowPowerModePublisherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LowPowerModePublisherTests.swift; sourceTree = ""; }; 614CADD62510BAC000B93D2D /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 615192CC2BD6948B0005A782 /* HTTPHeadersWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersWriterTests.swift; sourceTree = ""; }; @@ -3368,7 +3388,9 @@ 61BBD19624ED50040023E65F /* DatadogConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogConfigurationTests.swift; sourceTree = ""; }; 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifierTests.swift; sourceTree = ""; }; 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionScope.swift; sourceTree = ""; }; + B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDrawnConfiguration.swift; sourceTree = ""; }; 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionScopeTests.swift; sourceTree = ""; }; + 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDrawnConfigurationTests.swift; sourceTree = ""; }; 61C2C21124C5951400C0321C /* RUMViewScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMViewScope.swift; sourceTree = ""; }; 61C3637F2436164B00C4D4E6 /* ObjcExceptionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcExceptionHandlerTests.swift; sourceTree = ""; }; 61C3E63624BF191F008053F2 /* RUMScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMScope.swift; sourceTree = ""; }; @@ -3595,6 +3617,7 @@ D213532F270CA722000315AD /* DataCompressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompressionTests.swift; sourceTree = ""; }; D214DAA429E072D7004D0AE8 /* MessageBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBus.swift; sourceTree = ""; }; D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiver.swift; sourceTree = ""; }; + 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingReceiver.swift; sourceTree = ""; }; D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiver.swift; sourceTree = ""; }; D2160C9429C0DE5600FAA9A5 /* FirstPartyHosts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstPartyHosts.swift; sourceTree = ""; }; D2160C9629C0DE5600FAA9A5 /* TracingHeaderType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TracingHeaderType.swift; sourceTree = ""; }; @@ -3619,6 +3642,7 @@ D21C26D028A64599005DD405 /* MessageBusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBusTests.swift; sourceTree = ""; }; D21C26EA28AFA11E005DD405 /* LogMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogMessageReceiverTests.swift; sourceTree = ""; }; D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiverTests.swift; sourceTree = ""; }; + 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingReceiverTests.swift; sourceTree = ""; }; D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SessionReplay.swift"; sourceTree = ""; }; D224430C29E95D6600274EC7 /* CrashReportReceiverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReportReceiverTests.swift; sourceTree = ""; }; D22689C22EB12D3D00875E44 /* KSCrashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSCrashPlugin.swift; sourceTree = ""; }; @@ -3811,6 +3835,8 @@ D2B249962A45E10500DD4F9F /* LoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerTests.swift; sourceTree = ""; }; D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockTests.swift; sourceTree = ""; }; D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogCore.swift; sourceTree = ""; }; + 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingSnapshot.swift; sourceTree = ""; }; + 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingController.swift; sourceTree = ""; }; D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDHTTPHeadersWriter+apiTests.m"; sourceTree = ""; }; D2BCB11E29D30AF000737A9A /* URLSessionRUMResourcesHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRUMResourcesHandler.swift; sourceTree = ""; }; D2BCB12129D34A5F00737A9A /* URLSessionRUMResourcesHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRUMResourcesHandlerTests.swift; sourceTree = ""; }; @@ -3865,6 +3891,7 @@ D2DA23C3298D59DC00C6C7E6 /* DatadogInternalTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogInternalTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataEncryption.swift; sourceTree = ""; }; D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreRegistry.swift; sourceTree = ""; }; + 784964956777BCDE3B3162A7 /* RemoteSampling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSampling.swift; sourceTree = ""; }; D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchmarkURLSessionTaskDelegate.swift; sourceTree = ""; }; D2E8A8E62DCBBA5100CF7C63 /* RUMCoreContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMCoreContext.swift; sourceTree = ""; }; D2E8D59728C7AB90007E5DE1 /* ContextMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMessageReceiverTests.swift; sourceTree = ""; }; @@ -5541,6 +5568,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( + F1A5E11A0000000000000A01 /* RemoteSampling */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -6025,6 +6053,7 @@ D236BE2729520FED00676E67 /* CrashReportReceiver.swift */, D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */, D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */, + 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */, 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */, D2D748222DC0FF7E00C61353 /* FatalErrorContextNotifier.swift */, 5B1D02842E8EB78600AB2391 /* FlagEvaluationReceiver.swift */, @@ -6273,6 +6302,7 @@ 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */, 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */, 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */, + 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */, 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */, 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */, 6141CE652806B3F200EBB879 /* Utils */, @@ -6425,6 +6455,7 @@ 61494CB024C839460082C633 /* RUMResourceScope.swift */, 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */, 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */, + B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */, 61494CB924CB126F0082C633 /* RUMUserActionScope.swift */, 61C2C21124C5951400C0321C /* RUMViewScope.swift */, 61494B7827F3522C0082BBCC /* Utils */, @@ -7062,6 +7093,7 @@ isa = PBXGroup; children = ( D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */, + 784964956777BCDE3B3162A7 /* RemoteSampling.swift */, D23039B1298D5235001A1FA3 /* DatadogCoreProtocol.swift */, D23039BD298D5235001A1FA3 /* DatadogFeature.swift */, D23039AD298D5234001A1FA3 /* DD.swift */, @@ -7209,6 +7241,7 @@ A7E6EA832D314A9900997201 /* AnonymousIdentifierManagerTests.swift */, 615950EA291C029700470E0C /* SessionReplayDependencyTests.swift */, D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */, + 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */, 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */, D248ED4728081B9B00B315B4 /* TelemetryReceiverTests.swift */, 61C453492C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift */, @@ -7746,9 +7779,28 @@ path = Context; sourceTree = ""; }; + F1A5E11A0000000000000A01 /* RemoteSampling */ = { + isa = PBXGroup; + children = ( + 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */, + 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */, + ); + path = RemoteSampling; + sourceTree = ""; + }; + F1A5E11A0000000000000A02 /* RemoteSampling */ = { + isa = PBXGroup; + children = ( + C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */, + 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */, + ); + path = RemoteSampling; + sourceTree = ""; + }; D2EFA873286E010100F1FAA6 /* DatadogCore */ = { isa = PBXGroup; children = ( + F1A5E11A0000000000000A02 /* RemoteSampling */, 617699162A8608C20030022B /* Context */, 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */, 6167E70D2B83502200C3CA2D /* DatadogCore+FeatureDirectoriesTests.swift */, @@ -9708,6 +9760,8 @@ 61F930C22BA1C41A005F0EE2 /* TLVBlockReader.swift in Sources */, 613E793B2577B6EE00DFCC17 /* DataReader.swift in Sources */, D2B3F04D282A85FD00C2B5EE /* DatadogCore.swift in Sources */, + 3B8115C1224250F4CD369D23 /* RemoteSamplingSnapshot.swift in Sources */, + 6DF401C28146329A1EBE3761 /* RemoteSamplingController.swift in Sources */, A731B7E22EE08FBE003D1E4F /* ContextSharingTransformer.swift in Sources */, 2671348E2D688AD60048CB54 /* AccountInfoPublisher.swift in Sources */, 61133BD62423979B00786299 /* DataUploader.swift in Sources */, @@ -9791,6 +9845,8 @@ 6128F57E2BA8A3A000D35B08 /* DataStore+TLVTests.swift in Sources */, D224430D29E95D6700274EC7 /* CrashReportReceiverTests.swift in Sources */, 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */, + 12D14979916A036A2CC36DBE /* RemoteSamplingSnapshotTests.swift in Sources */, + 5CEEC145E560188306A44870 /* RemoteSamplingControllerTests.swift in Sources */, A731B7FB2EE096B5003D1E4F /* SharedContextTests.swift in Sources */, A731B7FC2EE096B5003D1E4F /* ContextSharingTransformerTests.swift in Sources */, 11F55FD62DCBBAD700DE4944 /* DDURLSessionInstrumentationTests+apiTests.m in Sources */, @@ -10334,6 +10390,7 @@ D23039E1298D5236001A1FA3 /* AppState.swift in Sources */, D2F448E22D43A3DC007BB995 /* CompletionHandler.swift in Sources */, D2DE63532A30A7CA00441A54 /* CoreRegistry.swift in Sources */, + 3CBF72DC4C4F98B6205D5576 /* RemoteSampling.swift in Sources */, D2D748402DC24F1100C61353 /* Crash.swift in Sources */, E2AA55EA2C32C76A002FEF28 /* WatchKitExtensions.swift in Sources */, D2EBEE2829BA160F00B15732 /* W3CHTTPHeadersWriter.swift in Sources */, @@ -10463,6 +10520,7 @@ 1124D5362EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, 1124D5372EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */, + 02A7E6777D3C038F8CF2AC39 /* RemoteSamplingReceiver.swift in Sources */, D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */, 965497062D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, 11F55FDA2DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, @@ -10506,6 +10564,7 @@ 11030D762D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, D2D748242DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */, + 32989812C6103BA00321C225 /* RUMDrawnConfiguration.swift in Sources */, A7E6EA812D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */, D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */, @@ -10538,6 +10597,7 @@ D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534B2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, + A36A655187BCCE5680EC5984 /* RUMDrawnConfigurationTests.swift in Sources */, 0904F9F52EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, 3C4CF9992C47CC92006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */, @@ -10570,6 +10630,7 @@ 61181CDD2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, 61C713BD2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */, + 224D398714B6DD75DD8044E9 /* RemoteSamplingReceiverTests.swift in Sources */, 61C713C12A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, 6105C5152D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, @@ -10956,6 +11017,7 @@ 1124D5382EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, 1124D5392EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, D224430429E9588100274EC7 /* TelemetryReceiver.swift in Sources */, + 8BB6657E07CFDC5C252B17E1 /* RemoteSamplingReceiver.swift in Sources */, D29A9F5729DD85BB005C54A4 /* URLSessionRUMResourcesHandler.swift in Sources */, 965497052D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, 11F55FD92DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, @@ -10999,6 +11061,7 @@ 11030D772D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, D2D748232DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, D29A9F5C29DD85BB005C54A4 /* RUMSessionScope.swift in Sources */, + 20EDC40A8B14129FB3E4928B /* RUMDrawnConfiguration.swift in Sources */, A7E6EA822D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, D29A9F6629DD85BB005C54A4 /* RUMUser.swift in Sources */, D29A9F8229DD85BB005C54A4 /* UIKitRUMUserActionsPredicate.swift in Sources */, @@ -11031,6 +11094,7 @@ D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, + DE687A414FCC3A4D328ABC7E /* RUMDrawnConfigurationTests.swift in Sources */, 0904F9F62EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, 3C4CF9982C47CC91006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, D29A9FAB29DDB483005C54A4 /* RUMUserActionScopeTests.swift in Sources */, @@ -11062,6 +11126,7 @@ 61181CDC2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, 61C713BC2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, D29A9FBB29DDB483005C54A4 /* ErrorMessageReceiverTests.swift in Sources */, + B1A879D1C2D68AD9AA4BB1AE /* RemoteSamplingReceiverTests.swift in Sources */, 61C713C02A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D29A9F9F29DDB483005C54A4 /* RUMApplicationScopeTests.swift in Sources */, 6105C5142D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, @@ -11256,6 +11321,8 @@ 3C0D5DE52A543E3500446CF9 /* EventGenerator.swift in Sources */, D2EFA869286DA85700F1FAA6 /* DatadogContextProvider.swift in Sources */, D2B3F04E282A85FD00C2B5EE /* DatadogCore.swift in Sources */, + FF1587B3CF5C9AC1CA800DAF /* RemoteSamplingSnapshot.swift in Sources */, + F7143B3DEDFBE78C328A89B9 /* RemoteSamplingController.swift in Sources */, 6128F5752BA3280300D35B08 /* DataStoreFileReader.swift in Sources */, D2303A0B298D5412001A1FA3 /* AsyncWriter.swift in Sources */, D224430729E95C2E00274EC7 /* MessageBus.swift in Sources */, @@ -11361,6 +11428,8 @@ D2CB6F1327C520D400A62B57 /* DDConfigurationTests.swift in Sources */, D2CB6F1727C520D400A62B57 /* ObjcExceptionHandlerTests.swift in Sources */, 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */, + 282EF63D069B9056BD53AF91 /* RemoteSamplingSnapshotTests.swift in Sources */, + 0E81AE3C56D66A15CCF3B697 /* RemoteSamplingControllerTests.swift in Sources */, D28F836B29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift in Sources */, D2CB6F1827C520D400A62B57 /* DatadogTestsObserver.swift in Sources */, D2CB6F1927C520D400A62B57 /* RequestBuilderTests.swift in Sources */, @@ -11561,6 +11630,7 @@ D28FB6972DB7D3F000CD76D0 /* RUMDataModels.swift in Sources */, D2F448E12D43A3DC007BB995 /* CompletionHandler.swift in Sources */, D2DE63542A30A7CA00441A54 /* CoreRegistry.swift in Sources */, + 70B102EEA2DD2A8186D31B62 /* RemoteSampling.swift in Sources */, E2AA55EC2C32C78B002FEF28 /* WatchKitExtensions.swift in Sources */, D2EBEE3629BA161100B15732 /* W3CHTTPHeadersWriter.swift in Sources */, D2DA236D298D57AA00C6C7E6 /* DeviceInfo.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 1ef1ef7426..69ddf9d9e8 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,6 +55,23 @@ internal final class DatadogCore { /// The message-bus instance. let bus = MessageBus() + /// Keeps the remote sampling configuration in step with the console. + /// + /// Created lazily because it captures `self`: every publication of `RemoteSamplingSource` + /// (RUM does it at SDK init and at every session creation) is one opportunity to fetch. + private(set) lazy var remoteSamplingController = RemoteSamplingController( + httpClient: httpClient, + contextProvider: contextProvider, + store: try? RemoteSamplingSnapshotStore(coreDirectory: directory), + telemetry: telemetry, + publishRates: { [weak self] rates in + self?.contextProvider.write { $0.set(additionalContext: rates) } + }, + notifyImmediateChange: { [weak self] in + self?.send(message: .payload(RemoteSamplingChangedMessage()), else: {}) + } + ) + /// Registry for Features. @ReadWriteLock private(set) var stores: [String: (storage: FeatureStorage, upload: FeatureUpload)] = [:] @@ -389,7 +406,19 @@ extension DatadogCore: DatadogCoreProtocol { } func set(context: @escaping () -> Context?) where Context: AdditionalContext { - contextProvider.write { $0.set(additionalContext: context()) } + contextProvider.write { [weak self] coreContext in + // Evaluated on the context queue, never on the caller's thread. The closures features + // pass in here read their own scopes, and those scopes are mutated from this queue — + // so calling it anywhere else turns an ordinary read into a data race. + let value = context() + coreContext.set(additionalContext: value) + // RUM publishes its configuration source at SDK init and at every session creation; + // each publication is one opportunity for the controller to ask the console. No other + // context type is a trigger, so rates the controller itself publishes cannot loop. + if let source = value as? RemoteSamplingSource { + self?.remoteSamplingController.onSourcePublished(source) + } + } } func send(message: FeatureMessage, else fallback: @escaping () -> Void) { diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift new file mode 100644 index 0000000000..17b258933e --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift @@ -0,0 +1,206 @@ +/* + * 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 + +/// Keeps the stored sampling configuration in step with what the console says. +/// +/// The fetch model is session-driven: RUM publishes `RemoteSamplingSource` when the SDK starts and +/// again every time a session begins, and each publication is one opportunity to fetch. There is no +/// polling — the console's `ttl` is intentionally not honoured on iOS. A request in flight +/// deduplicates triggers; a failed request is retried after 5s and then 60s (±20% jitter), after +/// which the controller waits for the next trigger. Nothing here can hold up the SDK or interrupt +/// collection: a request that fails, times out or comes back unreadable leaves the stored values +/// exactly as they were. Wiping them on a bad minute would swing a whole fleet back to the rates it +/// was built with, which is the opposite of what someone who turned a knob deliberately wants. +internal final class RemoteSamplingController { + /// The delays before the first and second retry of a failed fetch. + static let retryDelays: [TimeInterval] = [5, 60] + + /// The queue every piece of state below lives on. + private let queue = DispatchQueue(label: "com.datadoghq.remote-sampling", target: .global(qos: .utility)) + + private let httpClient: HTTPClient + private let contextProvider: DatadogContextProvider + private let store: RemoteSamplingSnapshotStore? + private let telemetry: Telemetry + + /// Hands the rates to the core, which publishes them as additional context for all features. + private let publishRates: (RemoteSamplingRates) -> Void + /// Tells the core the console asked for an immediate change; the core notifies RUM on the bus. + private let notifyImmediateChange: () -> Void + /// Schedules a retry; injectable so tests do not wait. + private let schedule: (TimeInterval, @escaping () -> Void) -> Void + /// Applies ±20% jitter to a retry delay; injectable so tests are deterministic. + private let jitter: (TimeInterval) -> TimeInterval + + /// The snapshot currently in effect, mirrored to disk after every change. + private var snapshot: RemoteSamplingSnapshot = .empty + /// The storage key of the running configuration; `nil` until the first source is seen. + private var storageKey: String? + /// Whether the stored snapshot was already loaded for `storageKey`. + private var didLoadStoredSnapshot = false + /// Whether a fetch or a pending retry is in flight, deduplicating triggers. + private var inFlight = false + /// How many retries the current fetch chain already used. + private var retryAttempt = 0 + /// The last source a trigger arrived with; retries aim at the same address. + private var lastSource: RemoteSamplingSource? + + init( + httpClient: HTTPClient, + contextProvider: DatadogContextProvider, + store: RemoteSamplingSnapshotStore?, + telemetry: Telemetry, + publishRates: @escaping (RemoteSamplingRates) -> Void, + notifyImmediateChange: @escaping () -> Void, + schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil, + jitter: ((TimeInterval) -> TimeInterval)? = nil + ) { + self.httpClient = httpClient + self.contextProvider = contextProvider + self.store = store + self.telemetry = telemetry + self.publishRates = publishRates + self.notifyImmediateChange = notifyImmediateChange + self.schedule = schedule ?? { delay, work in + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: work) + } + self.jitter = jitter ?? { delay in + delay * Double.random(in: 0.8...1.2) + } + } + + /// Handles a publication of `RemoteSamplingSource`: one opportunity to fetch. + /// + /// Called by the core every time a feature sets the source, which RUM does at SDK init and at + /// every session creation. The first publication for a storage key also loads the snapshot + /// persisted by a previous launch and publishes it, so the values the console last provided + /// apply again before the network answers. + func onSourcePublished(_ source: RemoteSamplingSource) { + queue.async { + self.contextProvider.read { context in + self.queue.async { + self.handleTrigger(source: source, context: context) + } + } + } + } + + // MARK: - Private; every method below runs on `queue` + + private func handleTrigger(source: RemoteSamplingSource, context: DatadogContext) { + lastSource = source + let key = RemoteSamplingSnapshotStore.key(source: source, context: context) + if key != storageKey { + storageKey = key + didLoadStoredSnapshot = false + snapshot = .empty + } + if !didLoadStoredSnapshot { + didLoadStoredSnapshot = true + if let stored = store?.load(forKey: key), stored.version > 0 { + snapshot = stored + publishRates(stored.rates) + } + } + + guard !inFlight else { + return + } + inFlight = true + retryAttempt = 0 + fetch(source: source) + } + + private func fetch(source: RemoteSamplingSource) { + var components = URLComponents(url: source.configurationURL, resolvingAgainstBaseURL: false) + if snapshot.version > 0 { + // Telling the server which version this app runs is what lets the console answer + // "has my change reached everyone yet". + var items = components?.queryItems ?? [] + items.append(URLQueryItem(name: "applied_version", value: String(snapshot.version))) + components?.queryItems = items + } + + guard let url = components?.url else { + telemetry.error("Remote sampling: could not build the configuration URL from \(source.configurationURL)") + inFlight = false + return + } + + var request = URLRequest(url: url) + if let etag = snapshot.etag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + httpClient.fetch(request: request) { [weak self] result in + self?.queue.async { + self?.handleResponse(result) + } + } + } + + private func handleResponse(_ result: Result<(response: HTTPURLResponse, body: Data), Error>) { + switch result { + case .success((let response, let body)) where response.statusCode == 200: + do { + let parsed = try RemoteSamplingResponse.parse(body: body, etag: remoteSamplingETag(for: body)) + activate(parsed) + } catch { + telemetry.debug("Remote sampling: rejecting an invalid configuration response, keeping the previous one") + scheduleRetry() + } + case .success((let response, _)) where response.statusCode == 304: + inFlight = false + case .success((let response, _)): + telemetry.debug("Remote sampling: configuration request answered \(response.statusCode), keeping the previous values") + scheduleRetry() + case .failure(let error): + telemetry.debug("Remote sampling: configuration request failed (\(error.localizedDescription)), keeping the previous values") + scheduleRetry() + } + } + + /// Activates a validated snapshot all-or-nothing: persist it, publish its rates, and when the + /// console asked for an immediate change that really changes what this client draws with, + /// let RUM know so it ends the running session. + private func activate(_ parsed: RemoteSamplingResponse) { + let before = snapshot.rates + snapshot = parsed.snapshot + if let storageKey = storageKey { + store?.save(snapshot, forKey: storageKey) + } + publishRates(snapshot.rates) + inFlight = false + + let after = snapshot.rates + let drawChanged = before.sessionSampleRate != after.sessionSampleRate + if parsed.activation == .immediate && drawChanged { + notifyImmediateChange() + } + } + + private func scheduleRetry() { + guard retryAttempt < Self.retryDelays.count else { + // Out of retries: keep the stored values and wait for the next session trigger. + inFlight = false + return + } + let delay = jitter(Self.retryDelays[retryAttempt]) + retryAttempt += 1 + schedule(delay) { [weak self] in + self?.queue.async { + // The retry only fires when the chain is still expected to be in flight. + guard let self = self, self.inFlight, let source = self.lastSource else { + return + } + self.fetch(source: source) + } + } + } +} diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift new file mode 100644 index 0000000000..de3fd0313b --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -0,0 +1,275 @@ +/* + * 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 CommonCrypto +import Foundation +import DatadogInternal + +/// The last configuration the console provided, as the client keeps it. +/// +/// A snapshot is activated all-or-nothing: a response that does not parse or that carries a value +/// of the wrong shape is rejected whole and the previous snapshot stays in effect. +internal struct RemoteSamplingSnapshot: Equatable, Codable { + /// The console's version of this configuration. `0` means the console never provided one. + var version: Int64 + /// The validator the next conditional request is sent with; `nil` until the first `200`. + var etag: String? + /// Whether the console wants remote configuration applied at all. + /// + /// `false` is the kill switch: every knob is cleared (so the app falls back to the values it + /// was initialised with) but the version is kept, so the client still reports what it runs. + var enabled: Bool + var sessionSampleRate: SampleRate? + /// The console's custom values, as the raw JSON object they were delivered in. + var custom: String? + + /// The snapshot before the console answers for the first time. + static let empty = RemoteSamplingSnapshot( + version: 0, + etag: nil, + enabled: false, + sessionSampleRate: nil, + custom: nil + ) + + /// The rates RUM and Session Replay draw with. + var rates: RemoteSamplingRates { + guard enabled else { + // Kill switch: no knob and no custom values, only the version survives. + return RemoteSamplingRates( + sessionSampleRate: nil, + version: version + ) + } + return RemoteSamplingRates( + sessionSampleRate: sessionSampleRate, + version: version, + custom: custom + ) + } +} + +/// The outcome of reading a `200` response body against the configuration contract. +internal struct RemoteSamplingResponse: Equatable { + /// How the console wants the configuration to take effect. + enum Activation: String, Equatable { + /// New sessions draw with the new values; the running session is untouched. + case nextSession = "next_session" + /// The running session ends so the next one starts under the new values. + case immediate = "immediate" + } + + let snapshot: RemoteSamplingSnapshot + let activation: Activation +} + +/// An unparseable or invalid configuration response. Carries no details on purpose: +/// the handling is the same whatever is wrong — keep the old snapshot. +internal struct RemoteSamplingResponseError: Error {} + +extension RemoteSamplingResponse { + /// Reads a configuration response body. + /// + /// The contract is a flat JSON object. Keys the SDK does not know are ignored, so an older SDK + /// can talk to a newer console. A known key carrying the wrong type — or a rate outside + /// 0...100 — rejects the whole response: half-activated configuration is worse than none. + /// + /// - Parameters: + /// - body: The response body. + /// - etag: The validator computed from the body by the caller. + static func parse(body: Data, etag: String) throws -> RemoteSamplingResponse { + guard let json = try? JSONSerialization.jsonObject(with: body), + let root = json as? [String: Any] else { + throw RemoteSamplingResponseError() + } + + let version = try readVersion(root) + let enabled = try readBoolean(root, key: Contract.enabled, default: false) + let activation = try readActivation(root) + + guard enabled else { + // Kill switch: clear every knob, keep the version. `rum` and `custom` are not read — + // the console sends `rum: {}` here, and whatever it carries must not apply. + return RemoteSamplingResponse( + snapshot: RemoteSamplingSnapshot( + version: version, + etag: etag, + enabled: false, + sessionSampleRate: nil, + custom: nil + ), + activation: activation + ) + } + + let rum = try readDictionary(root, key: Contract.rum, required: false) ?? [:] + let custom = try readCustom(root) + + return RemoteSamplingResponse( + snapshot: RemoteSamplingSnapshot( + version: version, + etag: etag, + enabled: true, + sessionSampleRate: try readRate(rum, key: Contract.sessionSampleRate), + custom: custom + ), + activation: activation + ) + } + + // MARK: - Whitelisted readers + + private enum Contract { + static let version = "version" + static let enabled = "enabled" + static let activation = "activation" + static let rum = "rum" + static let custom = "custom" + static let sessionSampleRate = "sessionSampleRate" + } + + private static func readVersion(_ root: [String: Any]) throws -> Int64 { + guard let raw = root[Contract.version] else { + throw RemoteSamplingResponseError() // a configuration without a version cannot be reported back + } + guard let version = raw as? NSNumber, !isBoolean(raw), version.int64Value >= 0 else { + throw RemoteSamplingResponseError() + } + return version.int64Value + } + + private static func readBoolean(_ root: [String: Any], key: String, default defaultValue: Bool) throws -> Bool { + guard let raw = root[key] else { + return defaultValue + } + guard let number = raw as? NSNumber, isBoolean(raw) else { + throw RemoteSamplingResponseError() + } + return number.boolValue + } + + private static func readActivation(_ root: [String: Any]) throws -> Activation { + guard let raw = root[Contract.activation] else { + return .nextSession + } + guard let string = raw as? String, let activation = Activation(rawValue: string) else { + throw RemoteSamplingResponseError() + } + return activation + } + + private static func readDictionary(_ root: [String: Any], key: String, required: Bool) throws -> [String: Any]? { + guard let raw = root[key] else { + if required { + throw RemoteSamplingResponseError() + } + return nil + } + guard let dictionary = raw as? [String: Any] else { + throw RemoteSamplingResponseError() + } + return dictionary + } + + /// A rate the response did not send stays absent, so the value passed to init keeps applying. + /// A rate outside 0...100 rejects the whole response rather than being clamped: a rate we + /// cannot trust is not a rate to sample a customer's traffic with. + private static func readRate(_ rum: [String: Any], key: String) throws -> SampleRate? { + guard let raw = rum[key] else { + return nil + } + guard let number = raw as? NSNumber, !isBoolean(raw) else { + throw RemoteSamplingResponseError() + } + let rate = number.doubleValue + guard rate >= 0, rate <= 100 else { + throw RemoteSamplingResponseError() + } + return SampleRate(rate) + } + + /// Custom values are delivered to the host application as the raw JSON object they arrived in. + private static func readCustom(_ root: [String: Any]) throws -> String? { + guard let dictionary = try readDictionary(root, key: Contract.custom, required: false) else { + return nil + } + guard JSONSerialization.isValidJSONObject(dictionary), + let data = try? JSONSerialization.data(withJSONObject: dictionary, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + throw RemoteSamplingResponseError() + } + return string + } + + /// `JSONSerialization` bridges JSON booleans to `NSNumber`, so a type check needs care. + private static func isBoolean(_ value: Any) -> Bool { + CFGetTypeID(value as CFTypeRef) == CFBooleanGetTypeID() + } +} + +/// Computes the validator of a configuration response body: the first 16 hex characters of its +/// SHA-256, quoted, as the contract fixes it. Sent back as `If-None-Match`; the server answers +/// `304` when the body would be identical. +internal func remoteSamplingETag(for body: Data) -> String { + var digest: [UInt8] = Array(repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + _ = body.withUnsafeBytes { CC_SHA256($0.baseAddress, UInt32(body.count), &digest) } + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "\"\(hex.prefix(16))\"" +} + +/// Persists the last good snapshot on disk, so the first sessions after a cold start draw with +/// the values the console provided on a previous launch instead of the ones the app was built with. +/// +/// The storage key embeds everything that makes a configuration entry invalid for another +/// configuration: a cache-format version (bumped when the layout changes), the endpoint host, the +/// application (service and bundle id), the environment and the application version. The SDK +/// version is deliberately not part of it — an SDK update must not throw away the console's +/// answer. Entries another key cannot see are simply never read. +internal struct RemoteSamplingSnapshotStore { + /// Bumped when the persisted layout changes, so old entries are left behind instead of misread. + private static let cacheFormatVersion = "cfv1" + /// Subdirectory of the core directory the snapshots live in. + private static let subdirectory = "remote-config" + + private let directory: Directory + + init(coreDirectory: CoreDirectory) throws { + self.directory = try coreDirectory.coreDirectory.createSubdirectory(path: Self.subdirectory) + } + + /// Builds the storage key for the given source and application context. + static func key(source: RemoteSamplingSource, context: DatadogContext) -> String { + let material = [ + cacheFormatVersion, + source.configurationURL.host ?? "", + source.configurationURL.port.map { String($0) } ?? "", + context.service, + context.applicationBundleIdentifier, + context.env, + context.version + ].joined(separator: "|") + return sha256(material) + } + + func load(forKey key: String) -> RemoteSamplingSnapshot? { + let url = fileURL(forKey: key) + guard let data = try? Data(contentsOf: url) else { + return nil + } + return try? JSONDecoder().decode(RemoteSamplingSnapshot.self, from: data) + } + + func save(_ snapshot: RemoteSamplingSnapshot, forKey key: String) { + guard let data = try? JSONEncoder().encode(snapshot) else { + return + } + try? data.write(to: fileURL(forKey: key), options: .atomic) + } + + private func fileURL(forKey key: String) -> URL { + directory.url.appendingPathComponent(key) + } +} diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift new file mode 100644 index 0000000000..0b5a4269b0 --- /dev/null +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift @@ -0,0 +1,365 @@ +/* + * 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 DatadogInternal +import TestUtilities +import XCTest +@testable import DatadogCore + +class RemoteSamplingControllerTests: XCTestCase { + private let source = RemoteSamplingSource( + configurationURL: URL(string: "https://intake.example.com/api/v2/rum/config?client_token=t&sdk=ios")! + ) + + /// A stub client answering each fetch through a programmable handler. + private final class FetchStub: HTTPClient { + private let lock = NSLock() + private var _calls: [URLRequest] = [] + var calls: [URLRequest] { + lock.lock(); defer { lock.unlock() } + return _calls + } + var handler: (URLRequest) -> Result<(response: HTTPURLResponse, body: Data), Error> = { _ in + .failure(NSError.mockAny()) + } + + func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) {} + + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + // Recorded on receipt, and answered off the caller's thread — the same shape as a real + // HTTP client. Answering synchronously would run the whole exchange inside the + // controller's own queue hop, which is precisely when its in-flight guard cannot be + // observed to do anything. + lock.lock() + _calls.append(request) + lock.unlock() + let handler = self.handler + DispatchQueue.global(qos: .utility).async { + completion(handler(request)) + } + } + } + + /// Thread-safe record of everything the controller did. + private final class Recorder { + private let lock = NSLock() + private var _published: [RemoteSamplingRates] = [] + private var _immediateChangeCount = 0 + private var _scheduledDelays: [TimeInterval] = [] + private var _pendingWork: [() -> Void] = [] + + var published: [RemoteSamplingRates] { + lock.lock(); defer { lock.unlock() } + return _published + } + var immediateChangeCount: Int { + lock.lock(); defer { lock.unlock() } + return _immediateChangeCount + } + var scheduledDelays: [TimeInterval] { + lock.lock(); defer { lock.unlock() } + return _scheduledDelays + } + var pendingWork: [() -> Void] { + lock.lock(); defer { lock.unlock() } + return _pendingWork + } + + func publish(_ rates: RemoteSamplingRates) { + lock.lock(); _published.append(rates); lock.unlock() + } + func notifyImmediateChange() { + lock.lock(); _immediateChangeCount += 1; lock.unlock() + } + func schedule(_ delay: TimeInterval, work: @escaping () -> Void) { + lock.lock() + _scheduledDelays.append(delay) + _pendingWork.append(work) + lock.unlock() + } + } + + private struct Harness { + let client = FetchStub() + let recorder = Recorder() + let controller: RemoteSamplingController + + init(store: RemoteSamplingSnapshotStore? = nil, context: DatadogContext = .mockAny()) { + let recorder = self.recorder + controller = RemoteSamplingController( + httpClient: client, + contextProvider: DatadogContextProvider(context: context), + store: store, + telemetry: NOPTelemetry(), + publishRates: { recorder.publish($0) }, + notifyImmediateChange: { recorder.notifyImmediateChange() }, + schedule: { recorder.schedule($0, work: $1) }, + jitter: { $0 } // no jitter in tests + ) + } + } + + /// Waits until `condition` holds, polling briefly; every controller hop is async. + private func eventually( + _ condition: @autoclosure () -> Bool, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + XCTAssertTrue(condition(), "condition not met within \(timeout)s", file: file, line: line) + } + + // MARK: - Fetch model + + func testTriggerFetchesConfiguration() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + + eventually(harness.client.calls.count == 1) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + XCTAssertEqual(harness.recorder.published.last?.version, 1) + } + + func testInFlightFetchDeduplicatesTriggers() { + let harness = Harness() + let gate = DispatchSemaphore(value: 0) + harness.client.handler = { _ in + gate.wait() + return .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + harness.controller.onSourcePublished(source) + harness.controller.onSourcePublished(source) + // A trigger reaches the guard only after an asynchronous context read, so it must be given + // that hop before the fetch is released — otherwise this would be measuring which of the + // two landed first rather than whether the guard held. + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + gate.signal() + + eventually(harness.recorder.published.count == 1) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertEqual(harness.client.calls.count, 1, "triggers during a fetch must not start another one") + } + + func testAppliedVersionIsSentOnceKnown() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 42, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + let firstQuery = URLComponents(url: harness.client.calls[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertNil(firstQuery?.first(where: { $0.name == "applied_version" }), "no version to report on the very first request") + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + let query = URLComponents(url: harness.client.calls[1].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first(where: { $0.name == "applied_version" })?.value, "42") + } + + // MARK: - ETag & 304 + + func testETagStoredAndSentAsIfNoneMatch() { + let harness = Harness() + let body = #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)! + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), body)) } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 304), Data())) } + harness.controller.onSourcePublished(source) + + eventually(harness.client.calls.count == 2) + XCTAssertEqual( + harness.client.calls[1].value(forHTTPHeaderField: "If-None-Match"), + remoteSamplingETag(for: body) + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + XCTAssertEqual(harness.recorder.published.count, 1, "a 304 changes nothing") + } + + // MARK: - Backoff + + func testFailureRetriesAt5sThen60sThenWaitsForNextTrigger() { + let harness = Harness() + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + eventually(harness.recorder.scheduledDelays.count == 1) + XCTAssertEqual(harness.recorder.scheduledDelays[0], 5) + + harness.recorder.pendingWork[0]() + eventually(harness.client.calls.count == 2) + eventually(harness.recorder.scheduledDelays.count == 2) + XCTAssertEqual(harness.recorder.scheduledDelays[1], 60) + + harness.recorder.pendingWork[1]() + eventually(harness.client.calls.count == 3) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertEqual(harness.recorder.scheduledDelays.count, 2, "after two retries the controller waits for the next trigger") + + // A new trigger is a fresh opportunity, with its own two retries: + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 4) + } + + func testSuccessResetsBackoff() { + let harness = Harness() + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.scheduledDelays.count == 1) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + harness.recorder.pendingWork[0]() + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.scheduledDelays.count, 1, "a successful retry schedules nothing further") + } + + func testInvalidSnapshotKeepsOldValuesAndRetries() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + // An unreadable answer must not wipe the values in effect: + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), "garbage".data(using: .utf8)!)) } + harness.controller.onSourcePublished(source) + eventually(harness.recorder.scheduledDelays.count == 1) + XCTAssertEqual(harness.recorder.published.count, 1, "an invalid snapshot is rejected whole, the old one stays") + } + + // MARK: - Kill switch + + func testKillSwitchClearsValuesKeepsVersion() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #""" + { "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + """#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + XCTAssertEqual(harness.recorder.published.last?.custom, #"{"a":1}"#) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 43, "enabled": false, "rum": {} }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 2) + let rates = harness.recorder.published.last + XCTAssertTrue(rates?.isEmpty ?? false) + XCTAssertNil(rates?.custom) + XCTAssertEqual(rates?.version, 43, "the version survives the kill switch") + } + + // MARK: - Immediate activation + + func testImmediateActivationNotifiesOnlyWhenDrawChanges() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "activation": "immediate", "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1, "the draw changed from nothing to 20") + + // Same rates again: no change, no notification. + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 2) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1) + + // A real change under `next_session`: no notification either. + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 2, "enabled": true, "rum": { "sessionSampleRate": 30 } }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 3) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1) + } + + // MARK: - Persistence + + func testStoredSnapshotAppliesBeforeNetworkAnswers() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let store = try makeStore() + store.save( + RemoteSamplingSnapshot( + version: 42, + etag: "\"0123456789abcdef\"", + enabled: true, + sessionSampleRate: 20, + custom: nil + ), + forKey: RemoteSamplingSnapshotStore.key(source: source, context: .mockAny()) + ) + let harness = Harness(store: store, context: .mockAny()) + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20, "the persisted values apply before the fetch answers") + eventually(harness.client.calls.count == 1) + let request = harness.client.calls[0] + XCTAssertEqual(request.value(forHTTPHeaderField: "If-None-Match"), "\"0123456789abcdef\"") + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first(where: { $0.name == "applied_version" })?.value, "42") + } + + func testSnapshotIsPersistedOnSuccess() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let store = try makeStore() + let harness = Harness(store: store, context: .mockAny()) + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 1) + let stored = store.load(forKey: RemoteSamplingSnapshotStore.key(source: source, context: .mockAny())) + XCTAssertEqual(stored?.version, 42) + XCTAssertEqual(stored?.sessionSampleRate, 20) + XCTAssertNotNil(stored?.etag) + } + + // MARK: - Helpers + + private func makeStore() throws -> RemoteSamplingSnapshotStore { + let directory = try Directory(url: temporaryDirectory).createSubdirectory(path: "remote-sampling-tests-\(UUID().uuidString)") + return try RemoteSamplingSnapshotStore( + coreDirectory: CoreDirectory(osDirectory: directory, coreDirectory: directory) + ) + } +} diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift new file mode 100644 index 0000000000..06c8c73a96 --- /dev/null +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -0,0 +1,201 @@ +/* + * 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 DatadogInternal +import TestUtilities +import XCTest +@testable import DatadogCore + +class RemoteSamplingSnapshotTests: XCTestCase { + // MARK: - Parsing & validation + + func testParsesFullResponse() throws { + let body = """ + { + "version": 42, "ttl": 600, "enabled": true, + "activation": "next_session", + "refresh_on_foreground": false, + "rum": { + "sessionSampleRate": 20 + }, + "custom": { "viplist": ["u-1"] } + } + """.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.activation, .nextSession) + XCTAssertEqual(response.snapshot.version, 42) + XCTAssertTrue(response.snapshot.enabled) + XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + XCTAssertEqual(response.snapshot.custom, #"{"viplist":["u-1"]}"#) + } + + func testAbsentKnobsStayAbsentNotZero() throws { + let body = #"{ "version": 7, "enabled": true, "rum": {} }"#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertNil(response.snapshot.sessionSampleRate) + XCTAssertNil(response.snapshot.custom) + } + + func testIgnoresUnknownKeys() throws { + let body = #""" + { "version": 7, "enabled": true, "future-field": { "anything": 1 }, "rum": { "sessionSampleRate": 30, "futureKnob": 9 } } + """#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.snapshot.sessionSampleRate, 30) + } + + func testRejectsWholeSnapshotOnInvalidType() { + let bodies = [ + #"not json"#, + #"["array"]"#, + #"{ "version": "42", "enabled": true }"#, // version of wrong type + #"{ "enabled": true, "rum": {} }"#, // no version + #"{ "version": 1, "enabled": "yes" }"#, // enabled of wrong type + #"{ "version": 1, "enabled": true, "activation": "sometimes" }"#, // unknown activation + #"{ "version": 1, "enabled": true, "rum": "nope" }"#, // rum of wrong type + #"{ "version": 1, "enabled": true, "custom": "nope" }"#, // custom of wrong type + #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": "20" } }"#, // rate of wrong type + ] + + for string in bodies { + let body = string.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny()), "should reject: \(string)") + } + } + + func testRejectsWholeSnapshotOnOutOfRangeRate() { + for rate in [-1, 100.5] { + let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny())) + } + } + + func testAcceptsBoundaryRates() throws { + for rate in [0, 100] { + let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + XCTAssertEqual(response.snapshot.sessionSampleRate, SampleRate(rate)) + } + } + + func testKillSwitchClearsValuesKeepsVersion() throws { + let body = #""" + { "version": 43, "enabled": false, "rum": {}, "custom": { "viplist": ["u-1"] } } + """#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + let rates = response.snapshot.rates + + XCTAssertEqual(response.snapshot.version, 43) + XCTAssertFalse(response.snapshot.enabled) + XCTAssertNil(response.snapshot.custom) + XCTAssertTrue(rates.isEmpty) + XCTAssertNil(rates.sessionSampleRate) + XCTAssertNil(rates.custom) + XCTAssertEqual(rates.version, 43, "the version survives the kill switch") + } + + func testRatesReflectSnapshot() throws { + let body = #""" + { "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + """#.data(using: .utf8)! + + let rates = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()).snapshot.rates + + XCTAssertEqual(rates.sessionSampleRate, 20) + XCTAssertEqual(rates.version, 42) + XCTAssertEqual(rates.custom, #"{"a":1}"#) + } + + // MARK: - ETag + + func testETagIsQuotedHashPrefix() { + let body = #"{ "version": 1 }"#.data(using: .utf8)! + + let etag = remoteSamplingETag(for: body) + + XCTAssertTrue(etag.hasPrefix("\"")) + XCTAssertTrue(etag.hasSuffix("\"")) + XCTAssertEqual(etag.count, 18, "16 hex characters plus the quotes") + let same = remoteSamplingETag(for: body) + XCTAssertEqual(etag, same, "ETag must be stable for the same body") + let other = remoteSamplingETag(for: #"{ "version": 2 }"#.data(using: .utf8)!) + XCTAssertNotEqual(etag, other) + } + + // MARK: - Persistence + + func testStorageKeyComposition() { + let source = RemoteSamplingSource(configurationURL: URL(string: "https://intake.example.com/api/v2/rum/config?client_token=t")!) + let base: DatadogContext = .mockWith( + clientToken: "t", + service: "shop", + env: "prod", + version: "1.2.3", + sdkVersion: "2.0.0", + applicationBundleIdentifier: "com.example.shop" + ) + let key = RemoteSamplingSnapshotStore.key(source: source, context: base) + + // SDK version is deliberately not part of the key: + let otherSDK: DatadogContext = .mockWith( + clientToken: "t", + service: "shop", + env: "prod", + version: "1.2.3", + sdkVersion: "9.9.9", + applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherSDK)) + + // Everything else that identifies a configuration is: + let otherAppVersion: DatadogContext = .mockWith( + clientToken: "t", service: "shop", env: "prod", version: "9.9.9", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherAppVersion)) + + let otherEnv: DatadogContext = .mockWith( + clientToken: "t", service: "shop", env: "staging", version: "1.2.3", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherEnv)) + + let otherService: DatadogContext = .mockWith( + clientToken: "t", service: "other", env: "prod", version: "1.2.3", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherService)) + + let otherHost = RemoteSamplingSource(configurationURL: URL(string: "https://other.example.com/api/v2/rum/config")!) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: otherHost, context: base)) + } + + func testStoreRoundTrip() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let coreDirectory = CoreDirectory( + osDirectory: Directory(url: temporaryDirectory), + coreDirectory: try Directory(url: temporaryDirectory).createSubdirectory(path: "core-\(UUID().uuidString)") + ) + let store = try RemoteSamplingSnapshotStore(coreDirectory: coreDirectory) + let snapshot = RemoteSamplingSnapshot( + version: 42, + etag: "\"0123456789abcdef\"", + enabled: true, + sessionSampleRate: 20, + custom: #"{"a":1}"# + ) + + store.save(snapshot, forKey: "key") + XCTAssertEqual(store.load(forKey: "key"), snapshot) + XCTAssertNil(store.load(forKey: "other-key")) + } +} diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 2952c44e51..52d16bc6a3 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5032,6 +5032,13 @@ public struct RUMViewEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? + // FLASHCAT FORK: `rc_version` is a FlashCat addition on top of the shared schema; our + // intake reads it, others ignore it. It is patched in by hand because the fork does + // not fork the rum-events-format repo — reapplying `make rum-models-generate` will + // drop it and this patch must be restored. + /// The version of the console's remote configuration the session was drawn with + public let rcVersion: Int64? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -5046,6 +5053,8 @@ public struct RUMViewEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" + // FLASHCAT FORK: see `rcVersion` above. + case rcVersion = "rc_version" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -5056,18 +5065,21 @@ public struct RUMViewEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled + /// - rcVersion: The version of the console's remote configuration the session was drawn with /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, + rcVersion: Int64? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate + self.rcVersion = rcVersion self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift index 6cf8d6281a..99f514bea0 100644 --- a/DatadogInternal/Sources/RemoteSampling.swift +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -22,24 +22,48 @@ public struct RemoteSamplingSource: AdditionalContext, Equatable { } } -/// The sampling rates the console last provided. +/// The configuration values the console last provided. /// /// The core is the only writer; RUM and Session Replay read it to decide whether to keep a session -/// and whether to record it. A rate is absent — never zero — when the console did not set it, and +/// and whether to record it. A knob is absent — never zero — when the console did not set it, and /// the feature then keeps the value the app was initialised with. Reporting a zero we invented /// would silently stop collection nobody asked to stop. public struct RemoteSamplingRates: AdditionalContext, Equatable { public static let key = "remote-sampling-rates" public let sessionSampleRate: SampleRate? - public let sessionReplaySampleRate: SampleRate? - public init(sessionSampleRate: SampleRate?, sessionReplaySampleRate: SampleRate?) { + /// The version of the console configuration these values came from. + /// + /// It survives the kill switch: when the console disables remote configuration the values are + /// cleared but the version is kept, so the client can still report which version it runs. + /// `0` means the console never provided a configuration. + public let version: Int64 + + /// The console's custom values, as the raw JSON object they were delivered in. + /// + /// Delivery is the platform's job; the meaning belongs to the host application, which reads + /// them through `RUMMonitorProtocol.remoteConfig()`. Kept as raw JSON so any value shape the + /// console adds later reaches the app without an SDK update. + public let custom: String? + + public init( + sessionSampleRate: SampleRate?, + version: Int64 = 0, + custom: String? = nil + ) { self.sessionSampleRate = sessionSampleRate - self.sessionReplaySampleRate = sessionReplaySampleRate + self.version = version + self.custom = custom } - public var isEmpty: Bool { sessionSampleRate == nil && sessionReplaySampleRate == nil } + /// Whether the console left every knob unset — the kill switch state. + /// + /// Note the version is deliberately not part of this: an empty configuration still reports + /// the version it came from. + public var isEmpty: Bool { + sessionSampleRate == nil && custom == nil + } } /// Sent by the core when the console asked for a change to take effect immediately and the rates diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 670bdde85c..5c5b440db0 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -174,7 +174,9 @@ internal final class RUMFeature: DatadogRemoteFeature { predicate: nextViewActionPredicate ) }, - sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) } + sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, + remoteConfigurationEnabled: configuration.remoteConfigurationEnabled, + customEndpoint: configuration.customEndpoint ) self.monitor = Monitor( @@ -234,6 +236,7 @@ internal final class RUMFeature: DatadogRemoteFeature { featureScope: featureScope, monitor: monitor ), + RemoteSamplingReceiver(monitor: monitor), FlagEvaluationReceiver(monitor: monitor), WebViewEventReceiver( featureScope: featureScope, diff --git a/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift new file mode 100644 index 0000000000..44cf9e6d02 --- /dev/null +++ b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift @@ -0,0 +1,36 @@ +/* + * 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 DatadogInternal +import Foundation + +/// Receives the core's remote-sampling signals for the RUM feature. +/// +/// Two kinds of messages matter here: +/// - `RemoteSamplingChangedMessage`, sent when the console asked for an immediate change to the +/// rates this app draws with. RUM answers by ending the running session, so the next one starts +/// under the new rates. Ending and restarting is deliberate: a session that was not being +/// collected has no id and no history, so flipping its decision in place would invent a session +/// that appears to begin mid-use, and a collected session flipped off would simply stop, looking +/// like it ended early. +/// - context updates, which carry the console's custom values for the host application to read +/// through `RUMMonitorProtocol.remoteConfig()`. +internal struct RemoteSamplingReceiver: FeatureMessageReceiver { + let monitor: Monitor + + func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { + switch message { + case .payload(let payload as RemoteSamplingChangedMessage): + monitor.stopSession() + return true + case .context(let context): + monitor.remoteConfigCustom = context.additionalContext(ofType: RemoteSamplingRates.self)?.custom + return false // context updates are broadcast; claiming them would only suppress the fallback + default: + return false + } + } +} diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 9332491d99..94b7c3b405 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -306,6 +306,16 @@ extension RUM { /// Default: `false`. public var collectAccessibility: Bool + /// Enables the remote configuration of sampling rates from the console. + /// + /// When enabled, the SDK asks the console (at startup and whenever a new session starts) + /// for the sampling rates to draw new sessions with, and reports the configuration version + /// each session was drawn under on its view events. The values the app was initialised + /// with stay in effect for anything the console does not set. + /// + /// Default: `false` — no extra requests are made and behaviour is unchanged. + public var remoteConfigurationEnabled: Bool + /// Feature flags to preview features in RUM. public var featureFlags: FeatureFlags @@ -465,6 +475,7 @@ extension RUM.Configuration { /// - trackSlowFrames: Enables the collection of slow frames (view hitches). Default: `true`. /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. + /// - remoteConfigurationEnabled: Enables remote configuration of sampling rates from the console. Default: `false`. /// - featureFlags: Experimental feature flags. public init( applicationID: String, @@ -494,6 +505,7 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, + remoteConfigurationEnabled: Bool = false, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -523,6 +535,7 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility + self.remoteConfigurationEnabled = remoteConfigurationEnabled self.featureFlags = featureFlags } } diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index b52e03a616..f61d8402ce 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -25,4 +25,8 @@ internal struct RUMContext { var activeViewName: String? /// The ID of active user action. var activeUserActionID: RUMUUID? + + /// The configuration the current session was drawn with; `nil` when no remote configuration + /// is in effect. Fixed for the session's life — it never changes mid-session. + var drawnConfiguration: RUMDrawnConfiguration? = nil } diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index afd64a432e..10f3b54c48 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -111,6 +111,14 @@ internal class Monitor: RUMCommandSubscriber { private let rumUUIDGenerator: RUMUUIDGenerator private let telemetry: Telemetry + /// The console's custom values, as the raw JSON object they were delivered in. + /// + /// Kept up to date by `RemoteSamplingReceiver` from the rates the core publishes; read by the + /// host application through `RUMMonitorProtocol.remoteConfig()`. `nil` when remote + /// configuration is off, when the console set no custom values, or after a kill switch. + @ReadWriteLock + var remoteConfigCustom: String? + init( dependencies: RUMScopeDependencies, dateProvider: DateProvider @@ -162,7 +170,7 @@ internal class Monitor: RUMCommandSubscriber { sessionID: context.sessionID.rawValue.uuidString.lowercased(), viewID: context.activeViewID?.rawValue.uuidString.lowercased(), userActionID: context.activeUserActionID?.rawValue.uuidString.lowercased(), - viewServerTimeOffset: self.scopes.activeSession?.viewScopes.last?.serverTimeOffset + viewServerTimeOffset: self.scopes.activeSession?.viewScopes.last?.serverTimeOffset, ) } ) @@ -241,6 +249,16 @@ extension Monitor: RUMMonitorProtocol { process(command: RUMStopSessionCommand(time: dateProvider.now)) } + func remoteConfig() -> [String: Any]? { + guard let json = remoteConfigCustom, + let data = json.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data), + let dictionary = values as? [String: Any] else { + return nil + } + return dictionary + } + func reportAppFullyDisplayed() { process(command: RUMTimeToFullDisplayCommand(time: dateProvider.now)) } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift new file mode 100644 index 0000000000..1a8392628f --- /dev/null +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift @@ -0,0 +1,51 @@ +/* + * 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 DatadogInternal +import Foundation + +/// The configuration a RUM session was drawn with, fixed for the session's life. +/// +/// Sessions never flip: the draw happens once, at session creation, from the remote rates the core +/// last published (falling back to the value the app was initialised with when the console set +/// none), and the record below is what view events report. +internal struct RUMDrawnConfiguration: Equatable { + /// The session sample rate the session was drawn with — remote when set, init otherwise. + let sessionSampleRate: SampleRate + /// The console configuration version the session was drawn with (`rc_version`); 0 when no + /// remote configuration was in effect. + let version: Int64 + + /// Resolves the draw from the rates the core published. + /// + /// `nil` when no remote configuration is in effect at all: events then report the init values, + /// exactly as before remote configuration existed. + init?(rates: RemoteSamplingRates?, fallbackSessionSampleRate: SampleRate) { + guard let rates = rates else { + return nil + } + self.sessionSampleRate = rates.sessionSampleRate ?? fallbackSessionSampleRate + self.version = rates.version + } +} + +/// Builds the URL the core asks for the remote sampling configuration. +/// +/// A custom endpoint means the app was pointed at the customer's own host for the RUM intake, and +/// the configuration lives beside it there — which is exactly the layout the private-deployment +/// nginx template serves. +internal func remoteSamplingConfigurationURL(customEndpoint: URL?, context: DatadogContext) -> URL? { + let intake = customEndpoint ?? context.site.endpoint.appendingPathComponent("api/v2/rum") + var components = URLComponents(url: intake.appendingPathComponent("config"), resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "client_token", value: context.clientToken), + URLQueryItem(name: "sdk", value: "ios"), + URLQueryItem(name: "sdk_version", value: context.sdkVersion), + URLQueryItem(name: "env", value: context.env), + URLQueryItem(name: "app_version", value: context.version) + ] + return components?.url +} diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index fa3bbd7373..86ed7a74b9 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -57,6 +57,12 @@ internal struct RUMScopeDependencies { let appStateManager: AppStateManaging let watchdogTermination: WatchdogTerminationMonitor? + /// Whether the console may set sampling rates remotely. When `false` (the default) no + /// configuration is ever requested and every session draws with the init values. + let remoteConfigurationEnabled: Bool + /// The custom RUM intake the configuration endpoint sits next to, when the app set one. + let customEndpoint: URL? + /// A factory function that creates `ViewEndedMetricController` for each new view started. let viewEndedMetricFactory: () -> ViewEndedController @@ -96,7 +102,9 @@ internal struct RUMScopeDependencies { watchdogTermination: WatchdogTerminationMonitor?, networkSettledMetricFactory: @escaping (Date, String) -> TNSMetricTracking, interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking?, - sessionType: RUMSessionType? + sessionType: RUMSessionType?, + remoteConfigurationEnabled: Bool = false, + customEndpoint: URL? = nil ) { self.featureScope = featureScope self.rumApplicationID = rumApplicationID @@ -123,6 +131,8 @@ internal struct RUMScopeDependencies { self.viewEndedMetricFactory = viewEndedMetricFactory self.appStateManager = appStateManager self.watchdogTermination = watchdogTermination + self.remoteConfigurationEnabled = remoteConfigurationEnabled + self.customEndpoint = customEndpoint self.networkSettledMetricFactory = networkSettledMetricFactory self.interactionToNextViewMetricFactory = interactionToNextViewMetricFactory diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 001bc7519a..58f54bbeb7 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -82,6 +82,9 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { let startPrecondition: RUMSessionPrecondition? /// If events from this session should be sampled (send to Datadog). let isSampled: Bool + /// The configuration this session was drawn with; `nil` when no remote configuration is in + /// effect. Drawn once here and fixed for the session's life — sessions never flip. + let drawnConfiguration: RUMDrawnConfiguration? /// If the session is currently active. Set to `false` upon reaching the `EndReason`. var isActive: Bool { endReason == nil } /// If this is the very first session created in the current app process (`false` for session created upon expiration of a previous one). @@ -113,7 +116,16 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { self.parent = parent self.dependencies = dependencies self.applicationState = applicationState - self.isSampled = dependencies.sessionSampler.sample() + // The session draw: the console's rate wins when it set one, the init rate otherwise. + // Drawn once here; `isSampled` is a `let` and sessions never flip. + let remoteRates = context.additionalContext(ofType: RemoteSamplingRates.self) + self.isSampled = remoteRates?.sessionSampleRate + .map { Sampler(samplingRate: $0).sample() } + ?? dependencies.sessionSampler.sample() + self.drawnConfiguration = RUMDrawnConfiguration( + rates: remoteRates, + fallbackSessionSampleRate: dependencies.sessionSampler.samplingRate + ) self.startPrecondition = startPrecondition self.sessionUUID = isSampled ? dependencies.rumUUIDGenerator.generateUnique() : .nullUUID self.isInitialSession = isInitialSession @@ -152,6 +164,15 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { // Update fatal error context with recent RUM session state: dependencies.fatalErrorContext.sessionState = state + + // Every session creation is one opportunity to ask the console for the sampling + // configuration. Publishing the source is the whole signal: the core deduplicates a + // fetch in flight and applies its retry policy, and with the option off nothing is + // ever published, so nothing is ever requested. + if dependencies.remoteConfigurationEnabled, + let url = remoteSamplingConfigurationURL(customEndpoint: dependencies.customEndpoint, context: context) { + dependencies.featureScope.set(context: { RemoteSamplingSource(configurationURL: url) }) + } } /// Creates a new Session upon expiration of the previous one. @@ -206,6 +227,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { context.sessionID = sessionUUID context.isSessionActive = isActive context.sessionPrecondition = startPrecondition + context.drawnConfiguration = drawnConfiguration return context } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index bbcc039a11..84251929cf 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -564,13 +564,21 @@ extension RUMViewScope { // Retrieve Session Replay config if any let sessionReplayConfig = context.additionalContext(ofType: SessionReplayCoreContext.Configuration.self) + // The configuration this view's session was drawn with. The drawn session rate wins over + // the init value so extrapolation and audits line up with the draw that kept the session, + // and `rc_version` lets an auditor recover the exact console settings from its version + // history. With no remote configuration in effect the init values are reported, exactly + // as before remote configuration existed. + let drawnConfiguration = self.context.drawnConfiguration + let viewEvent = RUMViewEvent( dd: .init( browserSdkVersion: nil, cls: nil, configuration: .init( + rcVersion: drawnConfiguration.flatMap { $0.version > 0 ? $0.version : nil }, sessionReplaySampleRate: sessionReplayConfig.map { Double($0.sampleRate) }, - sessionSampleRate: Double(dependencies.sessionSampler.samplingRate), + sessionSampleRate: Double(drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate), startSessionReplayRecordingManually: sessionReplayConfig?.startRecordingManually, traceSampleRate: context.additionalContext(ofType: TraceCoreContext.Configuration.self) .map { Double($0.sampleRate) } diff --git a/DatadogRUM/Sources/RUMMonitorProtocol.swift b/DatadogRUM/Sources/RUMMonitorProtocol.swift index c5bb8497de..8facd93fe7 100644 --- a/DatadogRUM/Sources/RUMMonitorProtocol.swift +++ b/DatadogRUM/Sources/RUMMonitorProtocol.swift @@ -83,6 +83,17 @@ public protocol RUMMonitorProtocol: RUMMonitorViewProtocol, AnyObject { /// If the session is started because of a call to `addAction`, the last known view is restarted in the new session. func stopSession() + /// The custom values delivered with the console's remote configuration. + /// + /// Delivery is the SDK's job; the meaning of the values belongs to the application. They are + /// returned as decoded JSON (strings, numbers, booleans, arrays and dictionaries), exactly as + /// the console sent them. + /// + /// Returns `nil` when remote configuration is not enabled + /// (`RUM.Configuration.remoteConfigurationEnabled`), when the console set no custom values, + /// or after the console's kill switch. + func remoteConfig() -> [String: Any]? + /// Records the time to full display (TTFD) of the current app launch. /// The duration of the TTFD is calculated as the number of nanoseconds elapsed between the start of the app and the time of this call. func reportAppFullyDisplayed() @@ -443,6 +454,11 @@ extension RUMMonitorViewProtocol { // MARK: - NOP monitor +extension RUMMonitorProtocol { + /// Default no-op so conformers predating remote configuration keep compiling. + func remoteConfig() -> [String: Any]? { nil } +} + internal class NOPMonitor: RUMMonitorProtocol { private func warn(method: StaticString = #function) { DD.logger.critical( diff --git a/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift new file mode 100644 index 0000000000..025721060f --- /dev/null +++ b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift @@ -0,0 +1,73 @@ +/* + * 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 +@testable import DatadogRUM +@testable import TestUtilities + +class RemoteSamplingReceiverTests: XCTestCase { + private let featureScope = FeatureScopeMock() + + private lazy var monitor = Monitor( + dependencies: .mockWith(featureScope: featureScope), + dateProvider: DateProviderMock() + ) + + private lazy var receiver = RemoteSamplingReceiver(monitor: monitor) + + func testImmediateChangeMessageEndsRunningSession() throws { + monitor.notifySDKInit() + try XCTSkipIf(monitor.scopes.activeSession == nil, "no session to end") + + let handled = receiver.receive(message: .payload(RemoteSamplingChangedMessage()), from: NOPDatadogCore()) + + XCTAssertTrue(handled) + XCTAssertNil(monitor.scopes.activeSession, "the session ends so the next one starts under the new rates") + } + + func testContextMessageKeepsCustomValuesForHostApp() throws { + let rates = RemoteSamplingRates( + sessionSampleRate: nil, + version: 42, + custom: #"{"viplist":["u-1"],"flag":true}"# + ) + var context: DatadogContext = .mockAny() + context.set(additionalContext: rates) + + let handled = receiver.receive(message: .context(context), from: NOPDatadogCore()) + + XCTAssertFalse(handled, "context updates are broadcast, not claimed") + let remoteConfig = try XCTUnwrap(monitor.remoteConfig()) + XCTAssertEqual(remoteConfig["viplist"] as? [String], ["u-1"]) + XCTAssertEqual(remoteConfig["flag"] as? Bool, true) + } + + func testKillSwitchDropsCustomValuesForHostApp() { + var context: DatadogContext = .mockAny() + context.set(additionalContext: RemoteSamplingRates( + sessionSampleRate: nil, + version: 42, + custom: #"{"a":1}"# + )) + _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) + XCTAssertNotNil(monitor.remoteConfig()) + + // Kill switch: values cleared, version kept, custom gone. + context.set(additionalContext: RemoteSamplingRates( + sessionSampleRate: nil, + version: 43, + custom: nil + )) + _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) + + XCTAssertNil(monitor.remoteConfig()) + } + + func testNOPMonitorAnswersNilRemoteConfig() { + XCTAssertNil(NOPMonitor().remoteConfig()) + } +} diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift new file mode 100644 index 0000000000..639b57d020 --- /dev/null +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift @@ -0,0 +1,239 @@ +/* + * 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 +@testable import DatadogRUM +@testable import TestUtilities + +class RUMDrawnConfigurationTests: XCTestCase { + let writer = FileWriterMock() + + /// Owned by the test because `RUMSessionScope` holds its parent `unowned`: the mock's default + /// parent is a temporary, and reading through it after it is released traps. + private let parent = RUMContextProviderMock() + + private func context(rates: RemoteSamplingRates?) -> DatadogContext { + var context: DatadogContext = .mockAny() + context.set(additionalContext: rates) + return context + } + + // MARK: - Configuration URL + + func testConfigurationURLBuildsFromSiteIntake() throws { + let context: DatadogContext = .mockWith( + clientToken: "token-1", + env: "prod", + version: "1.2.3", + sdkVersion: "2.3.4" + ) + + let url = try XCTUnwrap(remoteSamplingConfigurationURL(customEndpoint: nil, context: context)) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + XCTAssertTrue(components.path.hasSuffix("/api/v2/rum/config")) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) }) + XCTAssertEqual(query["client_token"], "token-1") + XCTAssertEqual(query["sdk"], "ios") + XCTAssertEqual(query["sdk_version"], "2.3.4") + XCTAssertEqual(query["env"], "prod") + XCTAssertEqual(query["app_version"], "1.2.3") + } + + func testConfigurationURLBuildsNextToCustomEndpoint() throws { + let url = try XCTUnwrap(remoteSamplingConfigurationURL( + customEndpoint: URL(string: "https://custom.example.com/intake")!, + context: .mockAny() + )) + + XCTAssertEqual(url.host, "custom.example.com") + XCTAssertTrue(url.path.hasSuffix("/intake/config"), "the configuration lives beside a custom intake") + } + + // MARK: - Fetch trigger (session-driven, opt-in) + + func testWhenRemoteConfigurationEnabled_sessionCreationPublishesSource() { + let featureScope = FeatureScopeMock() + + _ = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith( + featureScope: featureScope, + remoteConfigurationEnabled: true, + customEndpoint: nil + ) + ) + + let source = featureScope.contextMock.additionalContext(ofType: RemoteSamplingSource.self) + XCTAssertNotNil(source, "every session creation is one opportunity to fetch") + } + + func testWhenRemoteConfigurationDisabled_sessionCreationPublishesNothing() { + let featureScope = FeatureScopeMock() + + _ = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith( + featureScope: featureScope, + remoteConfigurationEnabled: false + ) + ) + + XCTAssertNil( + featureScope.contextMock.additionalContext(ofType: RemoteSamplingSource.self), + "opt-out means zero extra requests" + ) + } + + // MARK: - Session draw + + func testSessionDrawUsesRemoteRateWhenSet() { + let rejected = RUMSessionScope.mockWith( + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0)), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + XCTAssertFalse(rejected.isSampled, "the console's 0% wins over the init 100%") + XCTAssertEqual(rejected.sessionUUID, .nullUUID) + + let kept = RUMSessionScope.mockWith( + context: context(rates: RemoteSamplingRates(sessionSampleRate: 100)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()) + ) + XCTAssertTrue(kept.isSampled, "the console's 100% wins over the init 0%") + XCTAssertNotEqual(kept.sessionUUID, .nullUUID) + } + + func testSessionDrawKeepsInitRateWhenConsoleSetNone() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: nil, version: 42)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()) + ) + XCTAssertFalse(scope.isSampled, "absent means keep the init value, absent is not zero") + } + + func testDrawRecordCarriesSessionConfiguration() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates( + sessionSampleRate: 20, + version: 42, + custom: #"{"a":1}"# + )), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + + let drawn = scope.drawnConfiguration + XCTAssertEqual(drawn?.sessionSampleRate, 20) + XCTAssertEqual(drawn?.version, 42) + XCTAssertEqual(scope.context.drawnConfiguration, drawn, "the record flows down the scope chain") + } + + func testDrawRecordResolvesInitRateFallback() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: nil)), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + + XCTAssertEqual(scope.drawnConfiguration?.sessionSampleRate, 80, "no console rate means the init rate is the drawn rate") + XCTAssertEqual(scope.drawnConfiguration?.version, 0) + } + + func testNoRemoteRatesMeansNoDrawRecord() { + let scope = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + XCTAssertNil(scope.drawnConfiguration) + } + + // MARK: - View event + + func testViewEventReportsDrawnConfiguration() throws { + let rates = RemoteSamplingRates( + sessionSampleRate: 20, + version: 42 + ) + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: rates), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + // ARC may release `sessionScope` at its last strong use, which is the line below — the view + // scope's reference to it is unowned and would then dangle. + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockAny(), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + + let event = try XCTUnwrap(writer.events(ofType: RUMViewEvent.self).first) + XCTAssertEqual(event.dd.configuration?.sessionSampleRate, 20, "the drawn rate is reported, not the init one") + XCTAssertEqual(event.dd.configuration?.rcVersion, 42, "the version the session was drawn with") + } + + func testViewEventWithoutRemoteConfigurationReportsInitValuesAndNoVersion() throws { + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + // ARC may release `sessionScope` at its last strong use, which is the line below — the view + // scope's reference to it is unowned and would then dangle. + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + + let event = try XCTUnwrap(writer.events(ofType: RUMViewEvent.self).first) + XCTAssertEqual(event.dd.configuration?.sessionSampleRate, 80) + XCTAssertNil(event.dd.configuration?.rcVersion, "no remote configuration, no rc_version") + } + + // MARK: - Encoding (fork patch) + + func testConfigurationEncodesRCVersion() throws { + let configuration = RUMViewEvent.DD.Configuration(rcVersion: 42, sessionSampleRate: 100) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(configuration)) as? [String: Any] + ) + XCTAssertEqual(json["rc_version"] as? Int, 42) + XCTAssertEqual(json["session_sample_rate"] as? Int, 100) + + // And it is absent when there is no remote configuration: + let initOnly = RUMViewEvent.DD.Configuration(sessionSampleRate: 100) + let initJSON = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(initOnly)) as? [String: Any] + ) + XCTAssertNil(initJSON["rc_version"]) + } +} diff --git a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift index 524f43a1e8..93de27c5ee 100644 --- a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift +++ b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift @@ -15,7 +15,7 @@ public class HTTPClientMock: HTTPClient { /// Closure providing the result for each request. private let result: (URLRequest) -> Result /// Body handed back by `fetch(request:)`, for requests that read a response rather than upload. - public var fetchBody: Data = Data() + public var fetchBody = Data() /// 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). diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index 41ecadccd7..ea7d5b74bf 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1071,7 +1071,9 @@ extension RUMScopeDependencies { interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking = { INVMetric(predicate: TimeBasedINVActionPredicate()) }, - sessionType: RUMSessionType? = nil + sessionType: RUMSessionType? = nil, + remoteConfigurationEnabled: Bool = false, + customEndpoint: URL? = nil ) -> RUMScopeDependencies { return RUMScopeDependencies( featureScope: featureScope, @@ -1100,7 +1102,9 @@ extension RUMScopeDependencies { watchdogTermination: watchdogTermination, networkSettledMetricFactory: networkSettledMetricFactory, interactionToNextViewMetricFactory: interactionToNextViewMetricFactory, - sessionType: sessionType + sessionType: sessionType, + remoteConfigurationEnabled: remoteConfigurationEnabled, + customEndpoint: customEndpoint ) } From 075388c5b3d6d19496176ddf6cb9f2bde86753d8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 05:10:25 -0700 Subject: [PATCH 3/9] refactor(rum): name the custom-values accessor getRemoteConfig The same call is `getRemoteConfig()` on web and Android. A host writing the same integration twice should not have to remember that one platform spells it differently, and `get` prefixes already appear in this SDK's public API. --- DatadogInternal/Sources/RemoteSampling.swift | 2 +- .../Sources/Integrations/RemoteSamplingReceiver.swift | 2 +- DatadogRUM/Sources/RUMMonitor/Monitor.swift | 4 ++-- DatadogRUM/Sources/RUMMonitorProtocol.swift | 4 ++-- .../Tests/Integrations/RemoteSamplingReceiverTests.swift | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift index 99f514bea0..9bf86ddfe2 100644 --- a/DatadogInternal/Sources/RemoteSampling.swift +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -43,7 +43,7 @@ public struct RemoteSamplingRates: AdditionalContext, Equatable { /// The console's custom values, as the raw JSON object they were delivered in. /// /// Delivery is the platform's job; the meaning belongs to the host application, which reads - /// them through `RUMMonitorProtocol.remoteConfig()`. Kept as raw JSON so any value shape the + /// them through `RUMMonitorProtocol.getRemoteConfig()`. Kept as raw JSON so any value shape the /// console adds later reaches the app without an SDK update. public let custom: String? diff --git a/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift index 44cf9e6d02..88bc44db32 100644 --- a/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift +++ b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift @@ -17,7 +17,7 @@ import Foundation /// that appears to begin mid-use, and a collected session flipped off would simply stop, looking /// like it ended early. /// - context updates, which carry the console's custom values for the host application to read -/// through `RUMMonitorProtocol.remoteConfig()`. +/// through `RUMMonitorProtocol.getRemoteConfig()`. internal struct RemoteSamplingReceiver: FeatureMessageReceiver { let monitor: Monitor diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 10f3b54c48..ccbaccacf8 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -114,7 +114,7 @@ internal class Monitor: RUMCommandSubscriber { /// The console's custom values, as the raw JSON object they were delivered in. /// /// Kept up to date by `RemoteSamplingReceiver` from the rates the core publishes; read by the - /// host application through `RUMMonitorProtocol.remoteConfig()`. `nil` when remote + /// host application through `RUMMonitorProtocol.getRemoteConfig()`. `nil` when remote /// configuration is off, when the console set no custom values, or after a kill switch. @ReadWriteLock var remoteConfigCustom: String? @@ -249,7 +249,7 @@ extension Monitor: RUMMonitorProtocol { process(command: RUMStopSessionCommand(time: dateProvider.now)) } - func remoteConfig() -> [String: Any]? { + func getRemoteConfig() -> [String: Any]? { guard let json = remoteConfigCustom, let data = json.data(using: .utf8), let values = try? JSONSerialization.jsonObject(with: data), diff --git a/DatadogRUM/Sources/RUMMonitorProtocol.swift b/DatadogRUM/Sources/RUMMonitorProtocol.swift index 8facd93fe7..b37147f46d 100644 --- a/DatadogRUM/Sources/RUMMonitorProtocol.swift +++ b/DatadogRUM/Sources/RUMMonitorProtocol.swift @@ -92,7 +92,7 @@ public protocol RUMMonitorProtocol: RUMMonitorViewProtocol, AnyObject { /// Returns `nil` when remote configuration is not enabled /// (`RUM.Configuration.remoteConfigurationEnabled`), when the console set no custom values, /// or after the console's kill switch. - func remoteConfig() -> [String: Any]? + func getRemoteConfig() -> [String: Any]? /// Records the time to full display (TTFD) of the current app launch. /// The duration of the TTFD is calculated as the number of nanoseconds elapsed between the start of the app and the time of this call. @@ -456,7 +456,7 @@ extension RUMMonitorViewProtocol { extension RUMMonitorProtocol { /// Default no-op so conformers predating remote configuration keep compiling. - func remoteConfig() -> [String: Any]? { nil } + func getRemoteConfig() -> [String: Any]? { nil } } internal class NOPMonitor: RUMMonitorProtocol { diff --git a/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift index 025721060f..7c8fc06999 100644 --- a/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift +++ b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift @@ -41,7 +41,7 @@ class RemoteSamplingReceiverTests: XCTestCase { let handled = receiver.receive(message: .context(context), from: NOPDatadogCore()) XCTAssertFalse(handled, "context updates are broadcast, not claimed") - let remoteConfig = try XCTUnwrap(monitor.remoteConfig()) + let remoteConfig = try XCTUnwrap(monitor.getRemoteConfig()) XCTAssertEqual(remoteConfig["viplist"] as? [String], ["u-1"]) XCTAssertEqual(remoteConfig["flag"] as? Bool, true) } @@ -54,7 +54,7 @@ class RemoteSamplingReceiverTests: XCTestCase { custom: #"{"a":1}"# )) _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) - XCTAssertNotNil(monitor.remoteConfig()) + XCTAssertNotNil(monitor.getRemoteConfig()) // Kill switch: values cleared, version kept, custom gone. context.set(additionalContext: RemoteSamplingRates( @@ -64,10 +64,10 @@ class RemoteSamplingReceiverTests: XCTestCase { )) _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) - XCTAssertNil(monitor.remoteConfig()) + XCTAssertNil(monitor.getRemoteConfig()) } func testNOPMonitorAnswersNilRemoteConfig() { - XCTAssertNil(NOPMonitor().remoteConfig()) + XCTAssertNil(NOPMonitor().getRemoteConfig()) } } From cbe41f436c596bb1aae30ada7121ab0d56bc40e7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 07:30:47 -0700 Subject: [PATCH 4/9] refactor(rum): drop what the narrowed scope left behind `RemoteSamplingRates.isEmpty` had no caller outside two test assertions, and a public symbol nobody needs is not worth publishing. `readDictionary` took a `required` flag both call sites passed as false, so the throwing branch could never run. The rest is residue from narrowing this feature to the session sample rate: two intermediate values that now read one field each, two misaligned continuation lines, a constant that no longer needs to be visible outside its file, and an explicit nil the surrounding properties do without. --- .../RemoteSampling/RemoteSamplingController.swift | 7 +++---- .../RemoteSampling/RemoteSamplingSnapshot.swift | 13 +++++-------- .../RemoteSamplingControllerTests.swift | 2 +- .../RemoteSamplingSnapshotTests.swift | 1 - DatadogInternal/Sources/RemoteSampling.swift | 8 -------- DatadogRUM/Sources/RUMContext/RUMContext.swift | 2 +- 6 files changed, 10 insertions(+), 23 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift index 17b258933e..999819dae5 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift @@ -19,7 +19,7 @@ import DatadogInternal /// was built with, which is the opposite of what someone who turned a knob deliberately wants. internal final class RemoteSamplingController { /// The delays before the first and second retry of a failed fetch. - static let retryDelays: [TimeInterval] = [5, 60] + private static let retryDelays: [TimeInterval] = [5, 60] /// The queue every piece of state below lives on. private let queue = DispatchQueue(label: "com.datadoghq.remote-sampling", target: .global(qos: .utility)) @@ -170,7 +170,7 @@ internal final class RemoteSamplingController { /// console asked for an immediate change that really changes what this client draws with, /// let RUM know so it ends the running session. private func activate(_ parsed: RemoteSamplingResponse) { - let before = snapshot.rates + let previousSessionSampleRate = snapshot.rates.sessionSampleRate snapshot = parsed.snapshot if let storageKey = storageKey { store?.save(snapshot, forKey: storageKey) @@ -178,8 +178,7 @@ internal final class RemoteSamplingController { publishRates(snapshot.rates) inFlight = false - let after = snapshot.rates - let drawChanged = before.sessionSampleRate != after.sessionSampleRate + let drawChanged = previousSessionSampleRate != snapshot.rates.sessionSampleRate if parsed.activation == .immediate && drawChanged { notifyImmediateChange() } diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift index de3fd0313b..974f6c5ac5 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -41,7 +41,7 @@ internal struct RemoteSamplingSnapshot: Equatable, Codable { // Kill switch: no knob and no custom values, only the version survives. return RemoteSamplingRates( sessionSampleRate: nil, - version: version + version: version ) } return RemoteSamplingRates( @@ -99,13 +99,13 @@ extension RemoteSamplingResponse { etag: etag, enabled: false, sessionSampleRate: nil, - custom: nil + custom: nil ), activation: activation ) } - let rum = try readDictionary(root, key: Contract.rum, required: false) ?? [:] + let rum = try readDictionary(root, key: Contract.rum) ?? [:] let custom = try readCustom(root) return RemoteSamplingResponse( @@ -161,11 +161,8 @@ extension RemoteSamplingResponse { return activation } - private static func readDictionary(_ root: [String: Any], key: String, required: Bool) throws -> [String: Any]? { + private static func readDictionary(_ root: [String: Any], key: String) throws -> [String: Any]? { guard let raw = root[key] else { - if required { - throw RemoteSamplingResponseError() - } return nil } guard let dictionary = raw as? [String: Any] else { @@ -193,7 +190,7 @@ extension RemoteSamplingResponse { /// Custom values are delivered to the host application as the raw JSON object they arrived in. private static func readCustom(_ root: [String: Any]) throws -> String? { - guard let dictionary = try readDictionary(root, key: Contract.custom, required: false) else { + guard let dictionary = try readDictionary(root, key: Contract.custom) else { return nil } guard JSONSerialization.isValidJSONObject(dictionary), diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift index 0b5a4269b0..82962e0440 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift @@ -273,7 +273,7 @@ class RemoteSamplingControllerTests: XCTestCase { eventually(harness.recorder.published.count == 2) let rates = harness.recorder.published.last - XCTAssertTrue(rates?.isEmpty ?? false) + XCTAssertNil(rates?.sessionSampleRate) XCTAssertNil(rates?.custom) XCTAssertEqual(rates?.version, 43, "the version survives the kill switch") } diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift index 06c8c73a96..e5fe556b89 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -98,7 +98,6 @@ class RemoteSamplingSnapshotTests: XCTestCase { XCTAssertEqual(response.snapshot.version, 43) XCTAssertFalse(response.snapshot.enabled) XCTAssertNil(response.snapshot.custom) - XCTAssertTrue(rates.isEmpty) XCTAssertNil(rates.sessionSampleRate) XCTAssertNil(rates.custom) XCTAssertEqual(rates.version, 43, "the version survives the kill switch") diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift index 9bf86ddfe2..1679afc07f 100644 --- a/DatadogInternal/Sources/RemoteSampling.swift +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -56,14 +56,6 @@ public struct RemoteSamplingRates: AdditionalContext, Equatable { self.version = version self.custom = custom } - - /// Whether the console left every knob unset — the kill switch state. - /// - /// Note the version is deliberately not part of this: an empty configuration still reports - /// the version it came from. - public var isEmpty: Bool { - sessionSampleRate == nil && custom == nil - } } /// Sent by the core when the console asked for a change to take effect immediately and the rates diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index f61d8402ce..f4e3448c3f 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -28,5 +28,5 @@ internal struct RUMContext { /// The configuration the current session was drawn with; `nil` when no remote configuration /// is in effect. Fixed for the session's life — it never changes mid-session. - var drawnConfiguration: RUMDrawnConfiguration? = nil + var drawnConfiguration: RUMDrawnConfiguration? } From 75797bde83da56289d4a0e4262b2b9a4d252f012 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:37:00 -0700 Subject: [PATCH 5/9] feat(rum): draw the first session under the stored configuration, and add the missing controls Three changes to remote sampling, all of them about the iOS SDK doing what the other platforms already do. The first session of every launch ignored the stored configuration. The rates reach RUM through the core context, which is written on its own queue, so a value published there is not visible to a draw that happens now - and the first session is drawn immediately after RUM.enable(). Worse, the controller that loads the snapshot was only built when RUM published its source, which happens inside the session scope's initialiser, after the draw. So every cold start drew its first session at the values the app was built with, and the console's setting only took hold from the second session on. A customer dialling a rate down to shed volume still got every first-session at the old rate. The stored snapshot is now read synchronously, through a small `RemoteSamplingReader` protocol, before RUM can draw anything: the feature primes it at init, and the draw reads the rates without a queue hop. The context stays the channel for every other feature. `setForcedSession()` collects a visitor regardless of the configured rates, for an allow-list or a support flow. A session that was not being collected ends and a collected one starts in its place; a session already being collected keeps running, because RUM cannot retro-collect what a running session already dropped. The forced state lasts for the process lifetime and rides to Session Replay on the core context, so a forced session comes out with replay rather than being dropped by replay's own draw. `beforeSampling` is consulted synchronously at every draw, with the rate that would apply and the console's custom values; return a rate to override it or nil to leave it alone. It runs after the console's rate so an allow-list can keep a visitor that rate would drop. A rate outside 0...100 is ignored - a mistake in the host application must never take a customer's collection down with it. Also here: - A configuration written to a `schema_version` this SDK does not read is refused whole rather than read field by field, and is not retried: the server answered, and asking again would fetch the same refusal. - The no-op default for getRemoteConfig() lived in an internal extension, so it satisfied nothing outside this module. NOPMonitor now implements both methods explicitly and warns, like every other method on it. --- DatadogCore/Sources/Core/DatadogCore.swift | 59 ++++++++--- .../RemoteSamplingController.swift | 56 +++++++++-- .../RemoteSamplingSnapshot.swift | 30 ++++++ .../RemoteSamplingControllerTests.swift | 59 ++++++++--- .../RemoteSamplingSnapshotTests.swift | 56 ++++++++--- .../Sources/Models/RUM/RUMCoreContext.swift | 10 +- DatadogInternal/Sources/RemoteSampling.swift | 22 +++++ DatadogRUM/Sources/Feature/RUMFeature.swift | 18 +++- DatadogRUM/Sources/RUMConfiguration.swift | 41 ++++++++ .../Sources/RUMContext/RUMContext.swift | 4 + DatadogRUM/Sources/RUMMonitor/Monitor.swift | 5 + .../Sources/RUMMonitor/RUMCommand.swift | 15 +++ .../Scopes/RUMApplicationScope.swift | 23 ++++- .../Scopes/RUMDrawnConfiguration.swift | 12 ++- .../Scopes/RUMScopeDependencies.swift | 13 ++- .../RUMMonitor/Scopes/RUMSessionScope.swift | 64 ++++++++++-- DatadogRUM/Sources/RUMMonitorProtocol.swift | 19 +++- .../Scopes/RUMApplicationScopeTests.swift | 90 +++++++++++++++++ .../Scopes/RUMDrawnConfigurationTests.swift | 98 +++++++++++++++++++ .../Recorder/RecordingCoordinator.swift | 5 +- .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 14 ++- 21 files changed, 642 insertions(+), 71 deletions(-) diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 69ddf9d9e8..5ffd08ca30 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -57,20 +57,32 @@ internal final class DatadogCore { /// Keeps the remote sampling configuration in step with the console. /// - /// Created lazily because it captures `self`: every publication of `RemoteSamplingSource` - /// (RUM does it at SDK init and at every session creation) is one opportunity to fetch. - private(set) lazy var remoteSamplingController = RemoteSamplingController( - httpClient: httpClient, - contextProvider: contextProvider, - store: try? RemoteSamplingSnapshotStore(coreDirectory: directory), - telemetry: telemetry, - publishRates: { [weak self] rates in - self?.contextProvider.write { $0.set(additionalContext: rates) } - }, - notifyImmediateChange: { [weak self] in - self?.send(message: .payload(RemoteSamplingChangedMessage()), else: {}) + /// Built on demand because it captures `self` and because an app that never asked for remote + /// configuration must not pay for the store's directory. `lazy` cannot do that job here: it is + /// reached both from the context queue (a published source) and from the thread that enables + /// RUM (the priming read), and Swift's lazy initialisation is not atomic. + @ReadWriteLock + private var _remoteSamplingController: RemoteSamplingController? + + var remoteSamplingController: RemoteSamplingController { + if let existing = _remoteSamplingController { + return existing } - ) + let created = RemoteSamplingController( + httpClient: httpClient, + contextProvider: contextProvider, + store: try? RemoteSamplingSnapshotStore(coreDirectory: directory), + telemetry: telemetry, + publishRates: { [weak self] rates in + self?.contextProvider.write { $0.set(additionalContext: rates) } + }, + notifyImmediateChange: { [weak self] in + self?.send(message: .payload(RemoteSamplingChangedMessage()), else: {}) + } + ) + _remoteSamplingController = created + return created + } /// Registry for Features. @ReadWriteLock @@ -663,3 +675,24 @@ internal let registerObjcExceptionHandlerOnce: () -> Void = { ObjcException.rethrow = __dd_private_ObjcExceptionHandler.rethrow return {} }() + +extension DatadogCore: RemoteSamplingReader { + /// Reads the stored configuration into effect before RUM can draw its first session under it. + /// + /// The address depends on RUM's own endpoint configuration, which the core does not otherwise + /// know, so the caller builds it from the context handed to the closure. + @discardableResult + func primeRemoteSampling(source: (DatadogContext) -> RemoteSamplingSource?) -> RemoteSamplingRates? { + let context = contextProvider.read() + guard let source = source(context) else { + return nil + } + return remoteSamplingController.prime(source: source, context: context) + } + + var remoteSamplingRates: RemoteSamplingRates? { + // Only the controller that was actually built can have rates; asking for one here would + // create it for an app that never opted in. + _remoteSamplingController?.currentRates + } +} diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift index 999819dae5..d9690bd514 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift @@ -40,6 +40,12 @@ internal final class RemoteSamplingController { /// The snapshot currently in effect, mirrored to disk after every change. private var snapshot: RemoteSamplingSnapshot = .empty + /// The rates the snapshot resolves to, readable without waiting on `queue`. + /// + /// The context is how these reach every other feature, but a session draw cannot wait for a + /// queue hop — it happens now, and the answer has to be the one already on disk. + @ReadWriteLock + private(set) var currentRates: RemoteSamplingRates? /// The storage key of the running configuration; `nil` until the first source is seen. private var storageKey: String? /// Whether the stored snapshot was already loaded for `storageKey`. @@ -93,21 +99,44 @@ internal final class RemoteSamplingController { // MARK: - Private; every method below runs on `queue` - private func handleTrigger(source: RemoteSamplingSource, context: DatadogContext) { + /// Loads what a previous launch stored for this source, exactly once per storage key. + /// + /// Runs on `queue` like every other piece of state here — reached either from a trigger or, + /// before any session exists, from `prime(source:context:)`. + private func loadStoredSnapshotIfNeeded(source: RemoteSamplingSource, context: DatadogContext) { lastSource = source let key = RemoteSamplingSnapshotStore.key(source: source, context: context) if key != storageKey { storageKey = key didLoadStoredSnapshot = false snapshot = .empty + currentRates = nil } - if !didLoadStoredSnapshot { - didLoadStoredSnapshot = true - if let stored = store?.load(forKey: key), stored.version > 0 { - snapshot = stored - publishRates(stored.rates) - } + guard !didLoadStoredSnapshot else { + return + } + didLoadStoredSnapshot = true + if let stored = store?.load(forKey: key), stored.version > 0 { + snapshot = stored + currentRates = stored.rates + publishRates(stored.rates) } + } + + /// Reads the stored configuration into effect before anything can be drawn under it. + /// + /// Blocking is the point: the caller is about to draw a session, and the whole reason the + /// snapshot is on disk is so that draw uses it rather than the values the app was built with. + /// It only touches storage — no request is made here. + func prime(source: RemoteSamplingSource, context: DatadogContext) -> RemoteSamplingRates? { + queue.sync { + loadStoredSnapshotIfNeeded(source: source, context: context) + } + return currentRates + } + + private func handleTrigger(source: RemoteSamplingSource, context: DatadogContext) { + loadStoredSnapshotIfNeeded(source: source, context: context) guard !inFlight else { return @@ -151,6 +180,18 @@ internal final class RemoteSamplingController { do { let parsed = try RemoteSamplingResponse.parse(body: body, etag: remoteSamplingETag(for: body)) activate(parsed) + } catch let error as RemoteSamplingUnsupportedSchemaError { + // The server answered; this SDK simply cannot use the answer until it is updated. + // Asking again would fetch the same refusal, so the ask is over. + telemetry.error( + """ + Remote sampling: ignoring a configuration written to schema version \ + \(error.received.map(String.init) ?? "none"); this SDK reads version \ + \(RemoteSamplingResponse.supportedSchemaVersion). Update the SDK to take the \ + console's settings again. + """ + ) + inFlight = false } catch { telemetry.debug("Remote sampling: rejecting an invalid configuration response, keeping the previous one") scheduleRetry() @@ -175,6 +216,7 @@ internal final class RemoteSamplingController { if let storageKey = storageKey { store?.save(snapshot, forKey: storageKey) } + currentRates = snapshot.rates publishRates(snapshot.rates) inFlight = false diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift index 974f6c5ac5..cbc1cab3a8 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -70,6 +70,15 @@ internal struct RemoteSamplingResponse: Equatable { /// the handling is the same whatever is wrong — keep the old snapshot. internal struct RemoteSamplingResponseError: Error {} +/// A configuration written to a contract this SDK does not read. +/// +/// Kept apart from `RemoteSamplingResponseError` because the two deserve opposite answers: an +/// unreadable body is worth asking again for, a schema we do not know is not — the next answer +/// would be the same refusal. +internal struct RemoteSamplingUnsupportedSchemaError: Error { + let received: Int? +} + extension RemoteSamplingResponse { /// Reads a configuration response body. /// @@ -86,6 +95,12 @@ extension RemoteSamplingResponse { throw RemoteSamplingResponseError() } + // Checked before anything else is read out of the body. The server states the shape it + // wrote, and a reader that guesses instead of checking is exactly what this field exists + // to prevent — which is why it has to be honoured by the first SDK that ships, not by a + // later one: only code already on the device can refuse. + try checkSchemaVersion(root) + let version = try readVersion(root) let enabled = try readBoolean(root, key: Contract.enabled, default: false) let activation = try readActivation(root) @@ -122,7 +137,22 @@ extension RemoteSamplingResponse { // MARK: - Whitelisted readers + /// The contract this SDK reads. Not the SDK version and not the settings version: it names the + /// SHAPE of the body, and the server bumps it only when a body would be misread by a reader + /// written against the previous shape. + static let supportedSchemaVersion = 1 + + private static func checkSchemaVersion(_ root: [String: Any]) throws { + guard let raw = root[Contract.schemaVersion], + let number = raw as? NSNumber, !isBoolean(raw), + number.intValue == supportedSchemaVersion else { + let received = (root[Contract.schemaVersion] as? NSNumber).map { $0.intValue } + throw RemoteSamplingUnsupportedSchemaError(received: received) + } + } + private enum Contract { + static let schemaVersion = "schema_version" static let version = "version" static let enabled = "enabled" static let activation = "activation" diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift index 82962e0440..12c5883d8f 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift @@ -121,7 +121,7 @@ class RemoteSamplingControllerTests: XCTestCase { func testTriggerFetchesConfiguration() { let harness = Harness() harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -137,7 +137,7 @@ class RemoteSamplingControllerTests: XCTestCase { let gate = DispatchSemaphore(value: 0) harness.client.handler = { _ in gate.wait() - return .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + return .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -158,7 +158,7 @@ class RemoteSamplingControllerTests: XCTestCase { func testAppliedVersionIsSentOnceKnown() { let harness = Harness() harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 42, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 42, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -176,7 +176,7 @@ class RemoteSamplingControllerTests: XCTestCase { func testETagStoredAndSentAsIfNoneMatch() { let harness = Harness() - let body = #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)! + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)! harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), body)) } harness.controller.onSourcePublished(source) @@ -228,17 +228,54 @@ class RemoteSamplingControllerTests: XCTestCase { eventually(harness.recorder.scheduledDelays.count == 1) harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) } harness.recorder.pendingWork[0]() eventually(harness.recorder.published.count == 1) XCTAssertEqual(harness.recorder.scheduledDelays.count, 1, "a successful retry schedules nothing further") } + func testUnsupportedSchemaKeepsOldValuesAndDoesNotRetry() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 99, "version": 2, "enabled": true, "rum": { "sessionSampleRate": 90 } }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + + // The server answered; this SDK cannot use the answer. Retrying would fetch the same + // refusal, so nothing is scheduled — and nothing of the refused body is published. + XCTAssertEqual(harness.recorder.scheduledDelays.count, 0) + XCTAssertEqual(harness.recorder.published.count, 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + } + + func testUnsupportedSchemaDoesNotWedgeTheController() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 99, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + + // A refusal must still end the fetch: the next trigger has to reach the network. + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + } + func testInvalidSnapshotKeepsOldValuesAndRetries() { let harness = Harness() harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -257,7 +294,7 @@ class RemoteSamplingControllerTests: XCTestCase { let harness = Harness() harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), #""" - { "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + { "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } """#.data(using: .utf8)!)) } @@ -267,7 +304,7 @@ class RemoteSamplingControllerTests: XCTestCase { XCTAssertEqual(harness.recorder.published.last?.custom, #"{"a":1}"#) harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 43, "enabled": false, "rum": {} }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 43, "enabled": false, "rum": {} }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -283,7 +320,7 @@ class RemoteSamplingControllerTests: XCTestCase { func testImmediateActivationNotifiesOnlyWhenDrawChanges() { let harness = Harness() harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 1, "enabled": true, "activation": "immediate", "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "activation": "immediate", "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) @@ -297,7 +334,7 @@ class RemoteSamplingControllerTests: XCTestCase { // A real change under `next_session`: no notification either. harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 2, "enabled": true, "rum": { "sessionSampleRate": 30 } }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 2, "enabled": true, "rum": { "sessionSampleRate": 30 } }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) eventually(harness.recorder.published.count == 3) @@ -342,7 +379,7 @@ class RemoteSamplingControllerTests: XCTestCase { let store = try makeStore() let harness = Harness(store: store, context: .mockAny()) harness.client.handler = { _ in - .success((.mockResponseWith(statusCode: 200), #"{ "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) } harness.controller.onSourcePublished(source) diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift index e5fe556b89..b3ca8bd538 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -15,6 +15,7 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testParsesFullResponse() throws { let body = """ { + "schema_version": 1, "version": 42, "ttl": 600, "enabled": true, "activation": "next_session", "refresh_on_foreground": false, @@ -35,7 +36,7 @@ class RemoteSamplingSnapshotTests: XCTestCase { } func testAbsentKnobsStayAbsentNotZero() throws { - let body = #"{ "version": 7, "enabled": true, "rum": {} }"#.data(using: .utf8)! + let body = #"{ "schema_version": 1, "version": 7, "enabled": true, "rum": {} }"#.data(using: .utf8)! let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) @@ -45,7 +46,7 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testIgnoresUnknownKeys() throws { let body = #""" - { "version": 7, "enabled": true, "future-field": { "anything": 1 }, "rum": { "sessionSampleRate": 30, "futureKnob": 9 } } + { "schema_version": 1, "version": 7, "enabled": true, "future-field": { "anything": 1 }, "rum": { "sessionSampleRate": 30, "futureKnob": 9 } } """#.data(using: .utf8)! let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) @@ -57,13 +58,13 @@ class RemoteSamplingSnapshotTests: XCTestCase { let bodies = [ #"not json"#, #"["array"]"#, - #"{ "version": "42", "enabled": true }"#, // version of wrong type - #"{ "enabled": true, "rum": {} }"#, // no version - #"{ "version": 1, "enabled": "yes" }"#, // enabled of wrong type - #"{ "version": 1, "enabled": true, "activation": "sometimes" }"#, // unknown activation - #"{ "version": 1, "enabled": true, "rum": "nope" }"#, // rum of wrong type - #"{ "version": 1, "enabled": true, "custom": "nope" }"#, // custom of wrong type - #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": "20" } }"#, // rate of wrong type + #"{ "schema_version": 1, "version": "42", "enabled": true }"#, // version of wrong type + #"{ "schema_version": 1, "enabled": true, "rum": {} }"#, // no version + #"{ "schema_version": 1, "version": 1, "enabled": "yes" }"#, // enabled of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "activation": "sometimes" }"#, // unknown activation + #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": "nope" }"#, // rum of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "custom": "nope" }"#, // custom of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": "20" } }"#, // rate of wrong type ] for string in bodies { @@ -74,14 +75,14 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testRejectsWholeSnapshotOnOutOfRangeRate() { for rate in [-1, 100.5] { - let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny())) } } func testAcceptsBoundaryRates() throws { for rate in [0, 100] { - let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) XCTAssertEqual(response.snapshot.sessionSampleRate, SampleRate(rate)) } @@ -89,7 +90,7 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testKillSwitchClearsValuesKeepsVersion() throws { let body = #""" - { "version": 43, "enabled": false, "rum": {}, "custom": { "viplist": ["u-1"] } } + { "schema_version": 1, "version": 43, "enabled": false, "rum": {}, "custom": { "viplist": ["u-1"] } } """#.data(using: .utf8)! let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) @@ -105,7 +106,7 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testRatesReflectSnapshot() throws { let body = #""" - { "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + { "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } """#.data(using: .utf8)! let rates = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()).snapshot.rates @@ -115,6 +116,35 @@ class RemoteSamplingSnapshotTests: XCTestCase { XCTAssertEqual(rates.custom, #"{"a":1}"#) } + // MARK: - Schema + + func testRefusesASchemaItDoesNotRead() { + let bodies = [ + #"{ "schema_version": 2, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, + #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, // no schema at all + #"{ "schema_version": "1", "version": 1, "enabled": true }"#, // schema of wrong type + #"{ "schema_version": true, "version": 1, "enabled": true }"#, // JSON bools bridge to NSNumber + ] + + for string in bodies { + let body = string.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny()), "should refuse: \(string)") { error in + // Refused as a schema we cannot read, not as an unreadable body: the two get + // opposite answers from the controller. + XCTAssertTrue(error is RemoteSamplingUnsupportedSchemaError, "should refuse on schema: \(string)") + } + } + } + + func testReadsTheSchemaItSupports() throws { + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"# + .data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + } + // MARK: - ETag func testETagIsQuotedHashPrefix() { diff --git a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift index 9217ad938b..b603351878 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift @@ -20,6 +20,11 @@ public struct RUMCoreContext: AdditionalContext, Equatable { public let userActionID: String? /// Current view related server time offset public let viewServerTimeOffset: TimeInterval? + /// FLASHCAT FORK - whether the host application forced this session to be collected. Session + /// Replay skips its own draw when it is set, because a forced session must come out with + /// replay: forcing exists to debug one visitor, and a replay-less recording of them is not the + /// thing that was asked for. + public let sessionForced: Bool /// Creates a RUM context. /// @@ -29,17 +34,20 @@ public struct RUMCoreContext: AdditionalContext, Equatable { /// - viewID: Current RUM view ID - standard UUID string, lowercased. It can be empty when view is being loaded. /// - userActionID: The ID of current RUM action (standard UUID `String`, lowercased). /// - viewServerTimeOffset: Current view related server time offset + /// - sessionForced: Whether the host application forced this session to be collected. public init( applicationID: String, sessionID: String, viewID: String? = nil, userActionID: String? = nil, - viewServerTimeOffset: TimeInterval? = nil + viewServerTimeOffset: TimeInterval? = nil, + sessionForced: Bool = false ) { self.applicationID = applicationID self.sessionID = sessionID self.viewID = viewID self.userActionID = userActionID self.viewServerTimeOffset = viewServerTimeOffset + self.sessionForced = sessionForced } } diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift index 1679afc07f..0426638003 100644 --- a/DatadogInternal/Sources/RemoteSampling.swift +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -68,3 +68,25 @@ public struct RemoteSamplingRates: AdditionalContext, Equatable { public struct RemoteSamplingChangedMessage { public init() {} } + +/// Synchronous access to the remote sampling configuration a previous launch stored. +/// +/// The session draw is synchronous; the context that carries these rates to features is not. A +/// value published onto the context queue becomes visible some time AFTER the draw that needed it, +/// so a feature reading only the context would draw the first session of every launch under the +/// values the app was built with, and take the console's setting from the second session on. That +/// is precisely the case the on-disk snapshot exists to cover, so it has to be readable without a +/// queue hop. +public protocol RemoteSamplingReader: AnyObject { + /// Loads what a previous launch stored, without waiting on any queue, so the first draw of + /// this launch already sees it. The load happens once; later calls are cheap. + /// + /// - Parameter source: builds the address to load for from the context, which the core reads + /// synchronously. Returning nil means there is nothing to load. + /// - Returns: the rates now in effect, or nil when nothing was stored. + @discardableResult + func primeRemoteSampling(source: (DatadogContext) -> RemoteSamplingSource?) -> RemoteSamplingRates? + + /// The rates in effect right now, readable synchronously. + var remoteSamplingRates: RemoteSamplingRates? { get } +} diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 5c5b440db0..1b7bfb5b5a 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -42,6 +42,20 @@ internal final class RUMFeature: DatadogRemoteFeature { ) let featureScope = core.scope(for: RUMFeature.self) + + // FLASHCAT FORK - read what a previous launch stored BEFORE the first session can be + // drawn. The context is how these rates reach other features, but it is written on its own + // queue, so a value published there is not yet visible to the draw that needs it — and the + // first session of a launch is drawn immediately after `RUM.enable()`. Without this, every + // cold start would draw its first session at the values the app was built with and only + // take the console's setting from the second session on. + let remoteSamplingReader: RemoteSamplingReader? = configuration.remoteConfigurationEnabled + ? core as? RemoteSamplingReader + : nil + remoteSamplingReader?.primeRemoteSampling { context in + remoteSamplingConfigurationURL(customEndpoint: configuration.customEndpoint, context: context) + .map { RemoteSamplingSource(configurationURL: $0) } + } let sessionEndedMetric = SessionEndedMetricController( telemetry: core.telemetry, sampleRate: configuration.debugSDK ? 100 : configuration.sessionEndedSampleRate, @@ -176,7 +190,9 @@ internal final class RUMFeature: DatadogRemoteFeature { }, sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, remoteConfigurationEnabled: configuration.remoteConfigurationEnabled, - customEndpoint: configuration.customEndpoint + customEndpoint: configuration.customEndpoint, + remoteSamplingRates: { [weak remoteSamplingReader] in remoteSamplingReader?.remoteSamplingRates }, + beforeSampling: configuration.beforeSampling ) self.monitor = Monitor( diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 94b7c3b405..b528253c09 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -316,6 +316,16 @@ extension RUM { /// Default: `false` — no extra requests are made and behaviour is unchanged. public var remoteConfigurationEnabled: Bool + /// Has the last word on session sampling. + /// + /// Called synchronously each time a new session is about to be drawn, with the rate that + /// would apply and the console's custom values; return a rate to override it, or nil to + /// leave it alone. It is the last step of the draw, after the console's rate, precisely so + /// an allow-list can keep collecting a visitor the console's rate would drop. + /// + /// Default: `nil` — the draw is exactly what the console and this configuration say. + public var beforeSampling: BeforeSamplingCallback? + /// Feature flags to preview features in RUM. public var featureFlags: FeatureFlags @@ -476,6 +486,7 @@ extension RUM.Configuration { /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. /// - remoteConfigurationEnabled: Enables remote configuration of sampling rates from the console. Default: `false`. + /// - beforeSampling: Has the last word on session sampling. Default: `nil`. /// - featureFlags: Experimental feature flags. public init( applicationID: String, @@ -506,6 +517,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, remoteConfigurationEnabled: Bool = false, + beforeSampling: BeforeSamplingCallback? = nil, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -536,6 +548,7 @@ extension RUM.Configuration { self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility self.remoteConfigurationEnabled = remoteConfigurationEnabled + self.beforeSampling = beforeSampling self.featureFlags = featureFlags } } @@ -573,3 +586,31 @@ extension RUM.Configuration.FeatureFlags { self[flag, default: false] } } + +/// What the SDK is about to draw a new session with, handed to `RUM.Configuration.beforeSampling`: +/// the rate that would apply (the console's where it published one, the value passed to init where +/// it did not) and the console's custom values, decoded. +public struct BeforeSamplingContext { + /// The rate, between 0 and 100, that would decide this session. + public let sessionSampleRate: SampleRate + /// The console's custom values, or nil when remote configuration is off or nothing is + /// published. The same content as `RUMMonitorProtocol.getRemoteConfig()`. + public let custom: [String: Any]? + + public init(sessionSampleRate: SampleRate, custom: [String: Any]?) { + self.sessionSampleRate = sessionSampleRate + self.custom = custom + } +} + +/// The application's last word on session sampling, called synchronously each time a new session +/// is about to be drawn. +/// +/// Return a rate to override the one the SDK was going to use — 100 always collects, 0 never does +/// — or nil to leave it alone. The typical use is an allow-list: keep every session of the handful +/// of users you are debugging while the fleet stays at a low rate. +/// +/// It runs inside session creation, so it must be fast and must not block. A rate outside 0...100 +/// is ignored and the incoming rate applies: a mistake here must never take a customer's +/// collection down with it. A session already under way is never re-decided. +public typealias BeforeSamplingCallback = (BeforeSamplingContext) -> SampleRate? diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index f4e3448c3f..8b9d0b55f6 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -29,4 +29,8 @@ internal struct RUMContext { /// The configuration the current session was drawn with; `nil` when no remote configuration /// is in effect. Fixed for the session's life — it never changes mid-session. var drawnConfiguration: RUMDrawnConfiguration? + + /// Whether the host application forced this session to be collected. Session Replay reads it + /// through the core context so a forced session comes out with replay. + var sessionForced: Bool = false } diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index ccbaccacf8..cb202e750c 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -171,6 +171,7 @@ internal class Monitor: RUMCommandSubscriber { viewID: context.activeViewID?.rawValue.uuidString.lowercased(), userActionID: context.activeUserActionID?.rawValue.uuidString.lowercased(), viewServerTimeOffset: self.scopes.activeSession?.viewScopes.last?.serverTimeOffset, + sessionForced: context.sessionForced ) } ) @@ -249,6 +250,10 @@ extension Monitor: RUMMonitorProtocol { process(command: RUMStopSessionCommand(time: dateProvider.now)) } + func setForcedSession() { + process(command: RUMSetForcedSessionCommand(time: dateProvider.now)) + } + func getRemoteConfig() -> [String: Any]? { guard let json = remoteConfigCustom, let data = json.data(using: .utf8), diff --git a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift index 970956d565..08d84871e3 100644 --- a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift +++ b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift @@ -62,6 +62,21 @@ internal struct RUMApplicationStartCommand: RUMCommand { let missedEventType: SessionEndedMetric.MissedEventType? = nil } +/// FLASHCAT FORK - `RUMMonitorProtocol.setForcedSession()`: from here on every draw keeps the +/// session, for the lifetime of the process. +internal struct RUMSetForcedSessionCommand: RUMCommand { + var time: Date + var globalAttributes: [AttributeKey: AttributeValue] = [:] + var attributes: [AttributeKey: AttributeValue] = [:] + var canStartApplicationLaunchView = false + let canStartBackgroundView = false + let shouldRestartLastViewAfterSessionExpiration = false + let shouldRestartLastViewAfterSessionStop = false + let canStartBackgroundViewAfterSessionStop = false + let isUserInteraction = false + let missedEventType: SessionEndedMetric.MissedEventType? = nil +} + internal struct RUMStopSessionCommand: RUMCommand { var time: Date var globalAttributes: [AttributeKey: AttributeValue] = [:] diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift index c149408b98..8762fa772b 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift @@ -21,6 +21,11 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { /// Might be re-created later according to session duration constraints. private(set) var sessionScopes: [RUMSessionScope] = [] + /// FLASHCAT FORK - set through `RUMMonitorProtocol.setForcedSession()`, read at every draw from + /// then on. Process-lifetime, like the debugging decision it represents: the host application + /// decides again on each launch. + private(set) var isForcedSession = false + /// The last active foreground view from the previous session. /// Used to restore the view when a new session starts after `sessionStop()`. private var lastActiveView: RUMViewScope? @@ -149,6 +154,17 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { let lastActiveForegroundView = activeSession?.viewScopes.first(where: { $0.isActiveView && $0.viewPath != RUMOffViewEventsHandlingRule.Constants.backgroundViewURL }) lastActiveView = lastActiveForegroundView ?? lastActiveView + if let forced = command as? RUMSetForcedSessionCommand { + isForcedSession = true + // A session already being collected keeps running: RUM cannot retro-collect what a + // running session already dropped, and cutting it in two would gain nothing. One that + // was NOT collected ends now, so a collected one starts in its place. + if activeSession?.isSampled != true { + _process(command: RUMStopSessionCommand(time: forced.time), context: context, writer: writer) + } + return + } + if command is RUMStopSessionCommand { applicationState.wasAnySessionStopped = true } @@ -234,7 +250,8 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, dependencies: dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForcedSession ) lastSessionEndReason = nil @@ -266,7 +283,8 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, transferActiveView: transferActiveView, - applicationState: applicationState + applicationState: applicationState, + isForced: isForcedSession ) sessionScopeDidUpdate(refreshedSession) lastActiveView = nil @@ -310,6 +328,7 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { context: context, dependencies: dependencies, applicationState: applicationState, + isForced: isForcedSession, resumingViewScope: resumeViewScope ? lastActiveView : nil ) lastActiveView = nil diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift index 1a8392628f..7f10da83fd 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift @@ -19,15 +19,21 @@ internal struct RUMDrawnConfiguration: Equatable { /// remote configuration was in effect. let version: Int64 - /// Resolves the draw from the rates the core published. + /// Records the draw that just happened. /// /// `nil` when no remote configuration is in effect at all: events then report the init values, /// exactly as before remote configuration existed. - init?(rates: RemoteSamplingRates?, fallbackSessionSampleRate: SampleRate) { + /// + /// - Parameters: + /// - rates: what the console had published at the moment of the draw. + /// - drawnSessionSampleRate: the rate the draw actually used — the console's, the init value, + /// or whatever `beforeSampling` returned. It is what the events report, because it is what + /// decided the session. + init?(rates: RemoteSamplingRates?, drawnSessionSampleRate: SampleRate) { guard let rates = rates else { return nil } - self.sessionSampleRate = rates.sessionSampleRate ?? fallbackSessionSampleRate + self.sessionSampleRate = drawnSessionSampleRate self.version = rates.version } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index 86ed7a74b9..d65f6dec7d 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -62,6 +62,13 @@ internal struct RUMScopeDependencies { let remoteConfigurationEnabled: Bool /// The custom RUM intake the configuration endpoint sits next to, when the app set one. let customEndpoint: URL? + /// The console's rates as they stand right now, read synchronously. + /// + /// A closure rather than a value: the draw has to see what is in effect at the draw, and the + /// core updates it whenever the console answers. Always nil when the app did not opt in. + let remoteSamplingRates: () -> RemoteSamplingRates? + /// The host application's last word on the draw, consulted after the console's rate. + let beforeSampling: BeforeSamplingCallback? /// A factory function that creates `ViewEndedMetricController` for each new view started. let viewEndedMetricFactory: () -> ViewEndedController @@ -104,7 +111,9 @@ internal struct RUMScopeDependencies { interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking?, sessionType: RUMSessionType?, remoteConfigurationEnabled: Bool = false, - customEndpoint: URL? = nil + customEndpoint: URL? = nil, + remoteSamplingRates: @escaping () -> RemoteSamplingRates? = { nil }, + beforeSampling: BeforeSamplingCallback? = nil ) { self.featureScope = featureScope self.rumApplicationID = rumApplicationID @@ -133,6 +142,8 @@ internal struct RUMScopeDependencies { self.watchdogTermination = watchdogTermination self.remoteConfigurationEnabled = remoteConfigurationEnabled self.customEndpoint = customEndpoint + self.remoteSamplingRates = remoteSamplingRates + self.beforeSampling = beforeSampling self.networkSettledMetricFactory = networkSettledMetricFactory self.interactionToNextViewMetricFactory = interactionToNextViewMetricFactory diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 58f54bbeb7..182b902a78 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -82,6 +82,10 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { let startPrecondition: RUMSessionPrecondition? /// If events from this session should be sampled (send to Datadog). let isSampled: Bool + /// If the host application forced this session to be collected through + /// `RUMMonitorProtocol.setForcedSession()`. Session Replay reads it so a forced session comes + /// out with replay rather than being dropped by replay's own draw. + let isForced: Bool /// The configuration this session was drawn with; `nil` when no remote configuration is in /// effect. Drawn once here and fixed for the session's life — sessions never flip. let drawnConfiguration: RUMDrawnConfiguration? @@ -111,20 +115,33 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { context: DatadogContext, dependencies: RUMScopeDependencies, applicationState: RUMApplicationState, + isForced: Bool = false, resumingViewScope: RUMViewScope? = nil ) { self.parent = parent self.dependencies = dependencies self.applicationState = applicationState - // The session draw: the console's rate wins when it set one, the init rate otherwise. - // Drawn once here; `isSampled` is a `let` and sessions never flip. - let remoteRates = context.additionalContext(ofType: RemoteSamplingRates.self) - self.isSampled = remoteRates?.sessionSampleRate - .map { Sampler(samplingRate: $0).sample() } - ?? dependencies.sessionSampler.sample() + self.isForced = isForced + // The session draw, in the order the three sources are allowed to speak: the console's + // rate when it published one, the init value otherwise, and finally the app's own hook. + // The hook is the last word precisely so an allow-list can keep collecting a visitor the + // console's rate would drop. + // + // The rates are read synchronously rather than from `context`: the context carries them to + // other features, but it is written on its own queue, so a value published there is not + // visible to THIS draw — which is the draw the stored snapshot exists for. + let remoteRates = dependencies.remoteSamplingRates() + ?? context.additionalContext(ofType: RemoteSamplingRates.self) + let drawnRate = Self.resolveSampleRate( + remoteRates: remoteRates, + dependencies: dependencies + ) + // A forced session skips the draw entirely: the app has said this visitor must be + // collected, and a coin flip could still say no. + self.isSampled = isForced || Sampler(samplingRate: drawnRate).sample() self.drawnConfiguration = RUMDrawnConfiguration( rates: remoteRates, - fallbackSessionSampleRate: dependencies.sessionSampler.samplingRate + drawnSessionSampleRate: drawnRate ) self.startPrecondition = startPrecondition self.sessionUUID = isSampled ? dependencies.rumUUIDGenerator.generateUnique() : .nullUUID @@ -182,7 +199,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { startPrecondition: RUMSessionPrecondition?, context: DatadogContext, transferActiveView: Bool, - applicationState: RUMApplicationState + applicationState: RUMApplicationState, + isForced: Bool = false ) { self.init( // If the expired session was marked as "initial" but didn’t track any views, mark this new session as the new "initial". @@ -192,7 +210,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, dependencies: expiredSession.dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForced ) // Transfer active View to new `RUMViewScope`: @@ -220,6 +239,32 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { } } + /// The rate this session is drawn at: the console's where it published one, the init value + /// otherwise, with the host application's hook having the final say. + private static func resolveSampleRate( + remoteRates: RemoteSamplingRates?, + dependencies: RUMScopeDependencies + ) -> SampleRate { + let base = remoteRates?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate + guard let hook = dependencies.beforeSampling else { + return base + } + let custom = remoteRates?.custom + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONSerialization.jsonObject(with: $0) } + .flatMap { $0 as? [String: Any] } + guard let override = hook(BeforeSamplingContext(sessionSampleRate: base, custom: custom)) else { + return base + } + // A rate we cannot trust is not a rate to sample a customer's traffic with, and a mistake + // in the host application must never take their collection down with it. + guard override >= 0, override <= 100 else { + dependencies.telemetry.error("beforeSampling returned \(override), which is not a rate; drawing at \(base) instead") + return base + } + return override + } + // MARK: - RUMContextProvider var context: RUMContext { @@ -228,6 +273,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { context.isSessionActive = isActive context.sessionPrecondition = startPrecondition context.drawnConfiguration = drawnConfiguration + context.sessionForced = isForced return context } diff --git a/DatadogRUM/Sources/RUMMonitorProtocol.swift b/DatadogRUM/Sources/RUMMonitorProtocol.swift index b37147f46d..92706c06d8 100644 --- a/DatadogRUM/Sources/RUMMonitorProtocol.swift +++ b/DatadogRUM/Sources/RUMMonitorProtocol.swift @@ -83,6 +83,18 @@ public protocol RUMMonitorProtocol: RUMMonitorViewProtocol, AnyObject { /// If the session is started because of a call to `addAction`, the last known view is restarted in the new session. func stopSession() + /// Forces the session to be collected, with Session Replay, regardless of the configured + /// sample rates. + /// + /// Call it when your own code decides a user needs debugging (an allow-list, a support flow). + /// A session that was not being collected ends and a collected one starts in its place; a + /// session already being collected keeps running, because RUM cannot retro-collect what a + /// running session already dropped. Calling again while the forced session runs does nothing. + /// + /// The forced state lasts for the process lifetime, so decide on each app start whether to + /// call again. + func setForcedSession() + /// The custom values delivered with the console's remote configuration. /// /// Delivery is the SDK's job; the meaning of the values belongs to the application. They are @@ -454,11 +466,6 @@ extension RUMMonitorViewProtocol { // MARK: - NOP monitor -extension RUMMonitorProtocol { - /// Default no-op so conformers predating remote configuration keep compiling. - func getRemoteConfig() -> [String: Any]? { nil } -} - internal class NOPMonitor: RUMMonitorProtocol { private func warn(method: StaticString = #function) { DD.logger.critical( @@ -475,6 +482,8 @@ internal class NOPMonitor: RUMMonitorProtocol { func removeAttribute(forKey key: AttributeKey) { warn() } func removeAttributes(forKeys keys: [AttributeKey]) {warn() } func stopSession() { warn() } + func setForcedSession() { warn() } + func getRemoteConfig() -> [String: Any]? { warn(); return nil } func reportAppFullyDisplayed() { warn() } func addError(message: String, type: String?, stack: String?, source: RUMErrorSource, attributes: [AttributeKey: AttributeValue], file: StaticString?, line: UInt?) { warn() } func addError(error: Error, source: RUMErrorSource, attributes: [AttributeKey: AttributeValue]) { warn() } diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift index c8742c9b1d..750c590f21 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift @@ -211,6 +211,96 @@ class RUMApplicationScopeTests: XCTestCase { XCTAssertNil(scope.activeSession) } + // MARK: - Forced session + + func testGivenUncollectedSession_whenForced_itEndsItSoACollectedOneReplacesIt() throws { + // Given: a session the draw threw away + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockRejectAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + XCTAssertEqual(scope.activeSession?.isSampled, false) + + // When + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + + // Then: the uncollected session is gone, and the next interaction starts a forced one + XCTAssertTrue(scope.isForcedSession) + XCTAssertNil(scope.activeSession) + + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(3), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + XCTAssertEqual(scope.activeSession?.isSampled, true, "the replacement session is collected despite a 0% draw") + XCTAssertEqual(scope.activeSession?.isForced, true) + } + + func testGivenCollectedSession_whenForced_itKeepsRunning() throws { + // RUM cannot retro-collect what a running session already dropped, so cutting a session + // that is already being collected in two would gain nothing. + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockKeepAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + let sessionBefore = try XCTUnwrap(scope.activeSession?.sessionUUID) + + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + + XCTAssertTrue(scope.isForcedSession) + XCTAssertEqual(scope.activeSession?.sessionUUID, sessionBefore, "the running session is untouched") + } + + func testForcedStateOutlivesTheSessionThatSetIt() throws { + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockRejectAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(3), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + + // A later stop must not hand the visitor back to the 0% draw: the forced state is + // process-lifetime, decided again only on the next launch. + _ = scope.process( + command: RUMStopSessionCommand.mockWith(time: currentTime.addingTimeInterval(4)), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(5), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + + XCTAssertEqual(scope.activeSession?.isSampled, true) + } + func testGivenStoppedSession_whenUserActionEvent_itStartsANewSession() throws { // Given let currentTime = Date() diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift index 639b57d020..fe05bf4e67 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift @@ -152,6 +152,104 @@ class RUMDrawnConfigurationTests: XCTestCase { XCTAssertNil(scope.drawnConfiguration) } + // MARK: - The synchronous read + + func testSessionDrawReadsRatesSynchronouslyNotFromTheContext() { + // The context is empty, exactly as it is for the first session of a launch: the core has + // loaded the stored snapshot but the write onto the context queue has not landed yet. The + // draw must still see the stored rate. + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith( + sessionSampler: .mockKeepAll(), + remoteSamplingRates: { RemoteSamplingRates(sessionSampleRate: 0, version: 3) } + ) + ) + + XCTAssertFalse(scope.isSampled, "the stored 0% must decide the first session, not the init 100%") + XCTAssertEqual(scope.drawnConfiguration?.version, 3) + } + + func testSessionDrawKeepsInitRateWhenNothingWasStored() { + // Negative control for the test above: with nothing stored and nothing on the context, the + // init rate decides — so the assertion above really is reading the stored value. + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll(), remoteSamplingRates: { nil }) + ) + + XCTAssertTrue(scope.isSampled) + XCTAssertNil(scope.drawnConfiguration) + } + + // MARK: - beforeSampling + + func testBeforeSamplingHasTheLastWordOverTheConsole() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockRejectAll(), beforeSampling: { _ in 100 }) + ) + + XCTAssertTrue(scope.isSampled, "an allow-list must be able to keep a visitor the console's rate would drop") + XCTAssertEqual(scope.drawnConfiguration?.sessionSampleRate, 100, "events report the rate the draw actually used") + } + + func testBeforeSamplingSeesTheRateAndCustomThatWouldApply() { + var seen: BeforeSamplingContext? + _ = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 42, version: 7, custom: #"{"vip":["u-1"]}"#)), + dependencies: .mockWith( + sessionSampler: .mockKeepAll(), + beforeSampling: { ctx in + seen = ctx + return nil + } + ) + ) + + XCTAssertEqual(seen?.sessionSampleRate, 42, "the hook is consulted after the console, so it sees the console's rate") + XCTAssertEqual(seen?.custom?["vip"] as? [String], ["u-1"]) + } + + func testBeforeSamplingKeepsTheIncomingRateWhenItReturnsNothingOrNonsense() { + for hook: BeforeSamplingCallback in [{ _ in nil }, { _ in 150 }, { _ in -1 }] { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockKeepAll(), beforeSampling: hook) + ) + XCTAssertFalse(scope.isSampled, "an unusable answer leaves the console's rate alone") + } + } + + // MARK: - Forced session + + func testForcedSessionSkipsTheDrawEntirely() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()), + isForced: true + ) + + XCTAssertTrue(scope.isSampled, "a coin flip could still say no, and the app has said it must not") + XCTAssertTrue(scope.context.sessionForced, "Session Replay reads this so a forced session comes out with replay") + } + + func testUnforcedSessionDoesNotClaimToBeForced() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + + XCTAssertFalse(scope.context.sessionForced) + } + // MARK: - View event func testViewEventReportsDrawnConfiguration() throws { diff --git a/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift b/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift index 8d8e0d6df2..0010f0e570 100644 --- a/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift +++ b/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift @@ -99,7 +99,10 @@ internal class RecordingCoordinator { private func onRUMContextChanged(rumContext: RUMCoreContext?) { if currentRUMContext?.sessionID != rumContext?.sessionID || currentRUMContext == nil { - isSampled = sampler.sample() + // FLASHCAT FORK - a session the host application forced skips replay's own draw: + // forcing exists to debug one visitor, and a replay-less recording of them is not the + // thing that was asked for. + isSampled = rumContext?.sessionForced == true || sampler.sample() } currentRUMContext = rumContext diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index ea7d5b74bf..e62c499827 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1073,7 +1073,9 @@ extension RUMScopeDependencies { }, sessionType: RUMSessionType? = nil, remoteConfigurationEnabled: Bool = false, - customEndpoint: URL? = nil + customEndpoint: URL? = nil, + remoteSamplingRates: @escaping () -> RemoteSamplingRates? = { nil }, + beforeSampling: BeforeSamplingCallback? = nil ) -> RUMScopeDependencies { return RUMScopeDependencies( featureScope: featureScope, @@ -1104,7 +1106,9 @@ extension RUMScopeDependencies { interactionToNextViewMetricFactory: interactionToNextViewMetricFactory, sessionType: sessionType, remoteConfigurationEnabled: remoteConfigurationEnabled, - customEndpoint: customEndpoint + customEndpoint: customEndpoint, + remoteSamplingRates: remoteSamplingRates, + beforeSampling: beforeSampling ) } @@ -1189,7 +1193,8 @@ extension RUMSessionScope { context: DatadogContext = .mockAny(), dependencies: RUMScopeDependencies = .mockAny(), applicationState: RUMApplicationState = .mockAny(), - hasReplay: Bool? = .mockAny() + hasReplay: Bool? = .mockAny(), + isForced: Bool = false ) -> RUMSessionScope { return RUMSessionScope( isInitialSession: isInitialSession, @@ -1198,7 +1203,8 @@ extension RUMSessionScope { startPrecondition: startPrecondition, context: context, dependencies: dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForced ) } // swiftlint:enable function_default_parameter_at_end From f5e0c60afa8dece34a2e55ff200f78a55193b9ae Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:08:18 -0700 Subject: [PATCH 6/9] fix(core): refuse a configuration older than the one already in force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from a review pass over the remote configuration channel. A body carrying an older version than the one in effect was applied unconditionally. Versions only ever climb — a rollback republishes the older content under a new number, and pruning removes the oldest rows — so a lower one can only be a stale copy from an edge cache or a proxy answering 200 with something it held on to. Applying it put the client back on settings the console had already replaced, and it then reported that older number, so the rollout view read as the change losing ground. The custom values an application reads through getRemoteConfig() reached the monitor only on the next context broadcast, which arrives on another queue. An app calling it right after enabling RUM — to decide whether to force a session, which is what the API exists for, at exactly the moment it happens — was told nothing had been published while the very same stored configuration was already deciding how its first session was drawn. They are now seeded from the same synchronous read that primes the rates. One test asked for the configuration again as soon as the first request had gone out, but the answer to that request is what releases the in-flight guard, so the second ask raced it and was sometimes dropped. Both tests that did this now wait for the exchange to finish rather than for the request to leave. Two comments claiming Session Replay reads these rates are corrected: it samples with the rate the application configured and follows RUM's decision about the session. --- .../RemoteSampling/RemoteSamplingController.swift | 12 ++++++++++++ .../Core/RemoteSampling/RemoteSamplingSnapshot.swift | 2 +- .../RemoteSamplingControllerTests.swift | 12 +++++++++++- DatadogInternal/Sources/RemoteSampling.swift | 10 ++++++---- DatadogRUM/Sources/Feature/RUMFeature.swift | 10 +++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift index d9690bd514..d701a5c97d 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift @@ -211,6 +211,18 @@ internal final class RemoteSamplingController { /// console asked for an immediate change that really changes what this client draws with, /// let RUM know so it ends the running session. private func activate(_ parsed: RemoteSamplingResponse) { + // A body older than what is already in force is a stale copy — an edge cache or a proxy + // answering 200 with something it held on to. Versions only ever climb: a rollback in the + // console republishes the older content under a new number, and pruning removes the oldest + // rows, so the newest version never goes down. Applying one that did would put this client + // back on settings the console has already replaced, and it would keep reporting that older + // number, so the rollout view would read as the change losing ground. + guard parsed.snapshot.version >= snapshot.version else { + telemetry.debug("Remote sampling: ignoring a configuration older than the one in force") + inFlight = false + return + } + let previousSessionSampleRate = snapshot.rates.sessionSampleRate snapshot = parsed.snapshot if let storageKey = storageKey { diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift index cbc1cab3a8..73087a272c 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -35,7 +35,7 @@ internal struct RemoteSamplingSnapshot: Equatable, Codable { custom: nil ) - /// The rates RUM and Session Replay draw with. + /// The rates RUM draws each new session with. var rates: RemoteSamplingRates { guard enabled else { // Kill switch: no knob and no custom values, only the version survives. diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift index 12c5883d8f..72517436a1 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift @@ -162,7 +162,12 @@ class RemoteSamplingControllerTests: XCTestCase { } harness.controller.onSourcePublished(source) - eventually(harness.client.calls.count == 1) + // Wait for the exchange to finish, not merely for the request to go out. The version this + // test is about is only known once the answer has been applied, and the same act of + // applying it is what releases the in-flight guard — so triggering again on the strength of + // the request alone races the response and the second trigger is sometimes dropped. + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.client.calls.count, 1) let firstQuery = URLComponents(url: harness.client.calls[0].url!, resolvingAgainstBaseURL: false)?.queryItems XCTAssertNil(firstQuery?.first(where: { $0.name == "applied_version" }), "no version to report on the very first request") @@ -267,6 +272,11 @@ class RemoteSamplingControllerTests: XCTestCase { harness.controller.onSourcePublished(source) eventually(harness.client.calls.count == 1) + // A refused body publishes nothing, so there is no state to wait on: give the controller + // the hop it needs to finish refusing, or the next trigger races the in-flight guard being + // released and is sometimes dropped. + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + // A refusal must still end the fetch: the next trigger has to reach the network. harness.controller.onSourcePublished(source) eventually(harness.client.calls.count == 2) diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift index 0426638003..657609ae3f 100644 --- a/DatadogInternal/Sources/RemoteSampling.swift +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -24,10 +24,12 @@ public struct RemoteSamplingSource: AdditionalContext, Equatable { /// The configuration values the console last provided. /// -/// The core is the only writer; RUM and Session Replay read it to decide whether to keep a session -/// and whether to record it. A knob is absent — never zero — when the console did not set it, and -/// the feature then keeps the value the app was initialised with. Reporting a zero we invented -/// would silently stop collection nobody asked to stop. +/// The core is the only writer and RUM the only reader: it draws each new session against these +/// values. Session Replay is not a reader — it samples with the rate the app configured and follows +/// RUM's decision about the session — so a replay rate is not delivered on this fork. A knob is +/// absent — never zero — when the console did not set it, and the feature then keeps the value the +/// app was initialised with. Reporting a zero we invented would silently stop collection nobody +/// asked to stop. public struct RemoteSamplingRates: AdditionalContext, Equatable { public static let key = "remote-sampling-rates" diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 1b7bfb5b5a..8468de1025 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -52,7 +52,7 @@ internal final class RUMFeature: DatadogRemoteFeature { let remoteSamplingReader: RemoteSamplingReader? = configuration.remoteConfigurationEnabled ? core as? RemoteSamplingReader : nil - remoteSamplingReader?.primeRemoteSampling { context in + let primedRemoteSampling = remoteSamplingReader?.primeRemoteSampling { context in remoteSamplingConfigurationURL(customEndpoint: configuration.customEndpoint, context: context) .map { RemoteSamplingSource(configurationURL: $0) } } @@ -200,6 +200,14 @@ internal final class RUMFeature: DatadogRemoteFeature { dateProvider: configuration.dateProvider ) + // FLASHCAT FORK - seed the custom values from the same synchronous read that primed the + // rates. They reach the monitor again on the next context broadcast, but that arrives on + // another queue, so an app calling `getRemoteConfig()` straight after `RUM.enable()` would + // be told nothing was published while the very same stored configuration was already + // deciding how its first session was drawn. Reading them to choose whether to force a + // session is exactly what the API is documented for, and that call happens at start-up. + self.monitor.remoteConfigCustom = primedRemoteSampling?.custom + if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { dependencies.renderLoopObserver?.register(refreshRateVital) } From f6175324d4809aef12ef38aa883d8fec6e00bbd2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:33:05 -0700 Subject: [PATCH 7/9] fix(rum): report the rate a session was drawn with on every event, not just views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from a review that read one behaviour across all the SDKs at once rather than one SDK at a time. Only the view event reported the rate the session was actually drawn with. Errors, actions, resources and long tasks reported the value passed to init, so a session drawn at the console's 10% told the backend, and anyone reading the explorer, that it had been drawn at 100%. Every event now reports the rate that decided the session, which is what the view event already did. The record of that draw was kept only when the console had published something, so an application that uses beforeSampling on its own — the hook applies whether or not remote configuration is enabled — drew at the hook's rate and then reported the init rate on every event. It is now kept whenever the draw moved away from init, whatever moved it. A response carrying no schema stamp at all was refused as a shape this build cannot read. A body without one is, by construction, the shape that existed before the stamp did, which is the shape this reader was written against; refusing it switches remote configuration silently off against a server that merely predates the field, with nothing to say so. Only a stamp that is present and unrecognised is a refusal now — which is what the web SDK already did, so the two no longer disagree about the same response. --- .../RemoteSamplingSnapshot.swift | 14 +++-- .../RemoteSamplingSnapshotTests.swift | 13 ++++- .../Scopes/RUMDrawnConfiguration.swift | 18 +++++-- .../RUMMonitor/Scopes/RUMResourceScope.swift | 4 +- .../RUMMonitor/Scopes/RUMSessionScope.swift | 3 +- .../Scopes/RUMUserActionScope.swift | 2 +- .../RUMMonitor/Scopes/RUMViewScope.swift | 4 +- .../Scopes/RUMDrawnConfigurationTests.swift | 54 +++++++++++++++++++ 8 files changed, 96 insertions(+), 16 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift index 73087a272c..262797427e 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -143,10 +143,16 @@ extension RemoteSamplingResponse { static let supportedSchemaVersion = 1 private static func checkSchemaVersion(_ root: [String: Any]) throws { - guard let raw = root[Contract.schemaVersion], - let number = raw as? NSNumber, !isBoolean(raw), - number.intValue == supportedSchemaVersion else { - let received = (root[Contract.schemaVersion] as? NSNumber).map { $0.intValue } + // A body carrying no stamp at all is, by construction, the shape that existed before the + // stamp did — which is the shape this reader was written against. Refusing it would switch + // remote configuration silently off for every client on this platform whenever it is + // pointed at a server that merely predates the field, and nothing would say so. Only a + // stamp we can see and do not recognise is a reason to refuse. + guard let raw = root[Contract.schemaVersion] else { + return + } + guard let number = raw as? NSNumber, !isBoolean(raw), number.intValue == supportedSchemaVersion else { + let received = (raw as? NSNumber).map { $0.intValue } throw RemoteSamplingUnsupportedSchemaError(received: received) } } diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift index b3ca8bd538..e9ccbbebf8 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -121,7 +121,6 @@ class RemoteSamplingSnapshotTests: XCTestCase { func testRefusesASchemaItDoesNotRead() { let bodies = [ #"{ "schema_version": 2, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, - #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, // no schema at all #"{ "schema_version": "1", "version": 1, "enabled": true }"#, // schema of wrong type #"{ "schema_version": true, "version": 1, "enabled": true }"#, // JSON bools bridge to NSNumber ] @@ -136,6 +135,18 @@ class RemoteSamplingSnapshotTests: XCTestCase { } } + func testReadsABodyWithNoSchemaStampAtAll() throws { + // A body with no stamp is, by construction, the shape that existed before the stamp did — + // the shape this reader was written against. Refusing it would switch remote configuration + // silently off for every client on this platform against a server that merely predates the + // field, and nothing would say so. + let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + } + func testReadsTheSchemaItSupports() throws { let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"# .data(using: .utf8)! diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift index 7f10da83fd..eeeb7c2174 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift @@ -21,20 +21,28 @@ internal struct RUMDrawnConfiguration: Equatable { /// Records the draw that just happened. /// - /// `nil` when no remote configuration is in effect at all: events then report the init values, - /// exactly as before remote configuration existed. + /// `nil` only when there is nothing to record — no configuration was in effect and the draw + /// used the value the app was initialised with, so events reporting that value are already + /// telling the truth, exactly as before remote configuration existed. + /// + /// A `beforeSampling` hook is reason enough on its own: it applies whether or not the console + /// is publishing anything, so an app that only uses the hook still draws at a rate that is not + /// the init value, and without a record every event would report the init value instead of the + /// rate that actually decided the session. /// /// - Parameters: /// - rates: what the console had published at the moment of the draw. /// - drawnSessionSampleRate: the rate the draw actually used — the console's, the init value, /// or whatever `beforeSampling` returned. It is what the events report, because it is what /// decided the session. - init?(rates: RemoteSamplingRates?, drawnSessionSampleRate: SampleRate) { - guard let rates = rates else { + /// - initialSessionSampleRate: the value the app was initialised with, to tell "nothing + /// happened" from "the draw moved". + init?(rates: RemoteSamplingRates?, drawnSessionSampleRate: SampleRate, initialSessionSampleRate: SampleRate) { + guard rates != nil || drawnSessionSampleRate != initialSessionSampleRate else { return nil } self.sessionSampleRate = drawnSessionSampleRate - self.version = rates.version + self.version = rates?.version ?? 0 } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift index 546f62df36..8bb2e1ed46 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift @@ -173,7 +173,7 @@ internal class RUMResourceScope: RUMScope { browserSdkVersion: nil, configuration: .init( sessionReplaySampleRate: nil, - sessionSampleRate: Double(dependencies.sessionSampler.samplingRate) + sessionSampleRate: Double(parent.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate) ), discarded: nil, rulePsr: traceSamplingRate, @@ -294,7 +294,7 @@ internal class RUMResourceScope: RUMScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(parent.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), session: .init( plan: .plan1, sessionPrecondition: parent.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 182b902a78..be951d2cf7 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -141,7 +141,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { self.isSampled = isForced || Sampler(samplingRate: drawnRate).sample() self.drawnConfiguration = RUMDrawnConfiguration( rates: remoteRates, - drawnSessionSampleRate: drawnRate + drawnSessionSampleRate: drawnRate, + initialSessionSampleRate: dependencies.sessionSampler.samplingRate ) self.startPrecondition = startPrecondition self.sessionUUID = isSampled ? dependencies.rumUUIDGenerator.generateUnique() : .nullUUID diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift index 4a74d2fe1a..ece2c936bd 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift @@ -155,7 +155,7 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { dd: .init( action: nil, browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index 84251929cf..cd43aefd3a 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -739,7 +739,7 @@ extension RUMViewScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition @@ -830,7 +830,7 @@ extension RUMViewScope { let longTaskEvent = RUMLongTaskEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), discarded: nil, session: .init( plan: .plan1, diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift index fe05bf4e67..f0fa7ec75b 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift @@ -316,6 +316,60 @@ class RUMDrawnConfigurationTests: XCTestCase { XCTAssertNil(event.dd.configuration?.rcVersion, "no remote configuration, no rc_version") } + func testEveryEventTypeReportsTheDrawnRateNotTheInitOne() throws { + // The view event is not the only one carrying the rate: the backend extrapolates from + // whatever each event reports, and a customer reading the explorer sees it on every row. + // An error that says the session was drawn at the init rate is simply a wrong number. + let rates = RemoteSamplingRates(sessionSampleRate: 20, version: 42) + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: rates), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockAny(), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + _ = scope.process( + command: RUMAddCurrentViewErrorCommand.mockWithErrorMessage(time: .mockAny(), message: .mockAny()), + context: .mockAny(), + writer: writer + ) + + let errorEvent = try XCTUnwrap(writer.events(ofType: RUMErrorEvent.self).first) + XCTAssertEqual(errorEvent.dd.configuration?.sessionSampleRate, 20, "an error reports the drawn rate too") + } + + func testBeforeSamplingAloneIsRecordedEvenWithoutRemoteConfiguration() throws { + // The hook applies whether or not the console publishes anything. Without a record, a + // session drawn at the hook's rate would report the init rate on every event — the rate + // that did not decide it. + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith( + sessionSampler: Sampler(samplingRate: 80), + remoteSamplingRates: { nil }, + beforeSampling: { _ in 100 } + ) + ) + + XCTAssertEqual(sessionScope.drawnConfiguration?.sessionSampleRate, 100) + XCTAssertEqual(sessionScope.drawnConfiguration?.version, 0, "no console configuration was in effect") + } + // MARK: - Encoding (fork patch) func testConfigurationEncodesRCVersion() throws { From c73a7260328fe756e0ea881fe2250123c0a9458f Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:56:22 -0700 Subject: [PATCH 8/9] refactor(rum): name the rate every event reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expression that resolves it — the rate the session was drawn with, falling back to the value the app was initialised with — was written out at six event sites. Six copies of a rule that must stay identical is how one of them ends up different; it is now stated once, on the context that carries the draw. --- DatadogRUM/Sources/RUMContext/RUMContext.swift | 12 ++++++++++-- .../Sources/RUMMonitor/Scopes/RUMResourceScope.swift | 4 ++-- .../RUMMonitor/Scopes/RUMUserActionScope.swift | 2 +- .../Sources/RUMMonitor/Scopes/RUMViewScope.swift | 6 +++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index 8b9d0b55f6..05da26d66f 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -26,10 +26,18 @@ internal struct RUMContext { /// The ID of active user action. var activeUserActionID: RUMUUID? - /// The configuration the current session was drawn with; `nil` when no remote configuration - /// is in effect. Fixed for the session's life — it never changes mid-session. + /// The configuration the current session was drawn with; `nil` when nothing moved the draw + /// away from the value the app was initialised with. Fixed for the session's life — it never + /// changes mid-session. var drawnConfiguration: RUMDrawnConfiguration? + /// The rate this session was actually drawn with, which is what every event it produces + /// reports. Falls back to the value the app was initialised with, because that is then the + /// rate that decided it. + func reportedSessionSampleRate(initialisedWith sampler: Sampler) -> Double { + Double(drawnConfiguration?.sessionSampleRate ?? sampler.samplingRate) + } + /// Whether the host application forced this session to be collected. Session Replay reads it /// through the core context so a forced session comes out with replay. var sessionForced: Bool = false diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift index 8bb2e1ed46..640b23c862 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift @@ -173,7 +173,7 @@ internal class RUMResourceScope: RUMScope { browserSdkVersion: nil, configuration: .init( sessionReplaySampleRate: nil, - sessionSampleRate: Double(parent.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate) + sessionSampleRate: parent.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler) ), discarded: nil, rulePsr: traceSamplingRate, @@ -294,7 +294,7 @@ internal class RUMResourceScope: RUMScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(parent.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: parent.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: parent.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift index ece2c936bd..e1a02fbf18 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift @@ -155,7 +155,7 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { dd: .init( action: nil, browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index cd43aefd3a..8f6374e9dc 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -578,7 +578,7 @@ extension RUMViewScope { configuration: .init( rcVersion: drawnConfiguration.flatMap { $0.version > 0 ? $0.version : nil }, sessionReplaySampleRate: sessionReplayConfig.map { Double($0.sampleRate) }, - sessionSampleRate: Double(drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate), + sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler), startSessionReplayRecordingManually: sessionReplayConfig?.startRecordingManually, traceSampleRate: context.additionalContext(ofType: TraceCoreContext.Configuration.self) .map { Double($0.sampleRate) } @@ -739,7 +739,7 @@ extension RUMViewScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition @@ -830,7 +830,7 @@ extension RUMViewScope { let longTaskEvent = RUMLongTaskEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.context.drawnConfiguration?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), discarded: nil, session: .init( plan: .plan1, From 4742cc8f346ea8dc72f063dce1fbbbfa05166b41 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 08:25:05 -0700 Subject: [PATCH 9/9] fix(core): read a null schema stamp as no stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absent key and a key holding an explicit null say the same thing — nothing was stamped — but only the first was read that way, so the same response was accepted by the Android and HarmonyOS SDKs and refused here. A field whose whole purpose is that every reader agrees about a response cannot be the one place they disagree. --- .../RemoteSampling/RemoteSamplingSnapshot.swift | 4 +++- .../RemoteSamplingSnapshotTests.swift | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift index 262797427e..a9f99b9a5a 100644 --- a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -148,7 +148,9 @@ extension RemoteSamplingResponse { // remote configuration silently off for every client on this platform whenever it is // pointed at a server that merely predates the field, and nothing would say so. Only a // stamp we can see and do not recognise is a reason to refuse. - guard let raw = root[Contract.schemaVersion] else { + // A key that is absent, or present as an explicit null, both say the same thing: nothing + // was stamped. The other SDKs read them the same way. + guard let raw = root[Contract.schemaVersion], !(raw is NSNull) else { return } guard let number = raw as? NSNumber, !isBoolean(raw), number.intValue == supportedSchemaVersion else { diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift index e9ccbbebf8..4d1e3b86fc 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -140,11 +140,16 @@ class RemoteSamplingSnapshotTests: XCTestCase { // the shape this reader was written against. Refusing it would switch remote configuration // silently off for every client on this platform against a server that merely predates the // field, and nothing would say so. - let body = #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)! - - let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + let bodies = [ + #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, // no key at all + #"{ "schema_version": null, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, + ] - XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + for string in bodies { + let body = string.data(using: .utf8)! + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + XCTAssertEqual(response.snapshot.sessionSampleRate, 20, "should read: \(string)") + } } func testReadsTheSchemaItSupports() throws {