diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ff26935be4..97184c5c62 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ # Global code owners - RUM Mobile Team -* @DataDog/rum-mobile +* @DataDog/rum-mobile @DataDog/rum-mobile-ios ## Docs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..6d627a2b5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,58 @@ +--- +name: Bug report +about: Create a report to help us improve the SDK. +title: '' +labels: 'bug' +assignees: '' + +--- + +### Describe the bug +A clear and concise description of what the bug is. + +### Reproduction + +Provide a self-contained, concise snippet of code that can be used to reproduce the issue. +For more complex issues provide a repo with the smallest sample that reproduces the bug. + +Avoid including business logic or unrelated code, it makes diagnosis more difficult. +The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce. + +Do not share secrets or sensitive information in the code sample. + +### Expected behavior +A clear and concise description of what you expected to happen. + +--- + +#### Datadog SDK version: + +_Which version of the Datadog SDK causes this problem? e.g. `2.5.0`_ + +#### Last working Datadog SDK version: + +_What is the last Datadog SDK version where this problem didn't occur? e.g. `1.1.0`_ + +#### Dependency Manager: + +_Which dependency manager do you use? e.g. Cocoapods / Carthage / SPM / ..._ + +#### Other toolset: + +_Do you use additional tools with your dependency manager? e.g. [CarthageCache](https://github.com/Wolox/carthage_cache)_ + +#### Xcode version: + +_e.g. `Xcode 11.5 (11E608c)`_ + +#### Swift version: + +_e.g. `5.1`_ + +#### Deployment Target: + +_What is the Deployment Target of your app? e.g. `iOS 12`, `iPhone` + `iPad`_ + +#### macOS version: + +_e.g. `macOS Catalina 10.15.5 (19F96)`_ diff --git a/.github/ISSUE_TEMPLATE/compilation_issue.md b/.github/ISSUE_TEMPLATE/compilation_issue.md index 840e566a2b..80bf197dfc 100644 --- a/.github/ISSUE_TEMPLATE/compilation_issue.md +++ b/.github/ISSUE_TEMPLATE/compilation_issue.md @@ -11,11 +11,21 @@ assignees: '' 📝 Give us the error message you receive, describe the problem and answer the questions. +### Reproduction + +Provide a self-contained, concise snippet of code that can be used to reproduce the issue. +For more complex issues provide a repo with the smallest sample that reproduces the bug. + +Avoid including business logic or unrelated code, it makes diagnosis more difficult. +The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce. + +Do not share secrets or sensitive information in the code sample. + --- #### Datadog SDK version: -_Which version of the Datadog SDK causes this problem? e.g. `1.2.0`_ +_Which version of the Datadog SDK causes this problem? e.g. `2.5.0`_ #### Last working Datadog SDK version: diff --git a/.github/ISSUE_TEMPLATE/crash_report.md b/.github/ISSUE_TEMPLATE/crash_report.md index f32d11f9a0..16a30671ae 100644 --- a/.github/ISSUE_TEMPLATE/crash_report.md +++ b/.github/ISSUE_TEMPLATE/crash_report.md @@ -11,6 +11,16 @@ assignees: '' 📝 Give us the crash report or stack trace, describe the problem in details and answer the questions. +### Reproduction + +Provide a self-contained, concise snippet of code that can be used to reproduce the issue. +For more complex issues provide a repo with the smallest sample that reproduces the bug. + +Avoid including business logic or unrelated code, it makes diagnosis more difficult. +The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce. + +Do not share secrets or sensitive information in the code sample. + --- #### Datadog SDK versions: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..3a869ad599 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'feature' +assignees: '' + +--- + +### Is your feature request related to a problem? Please describe. + +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +### Describe the solution you'd like +A clear and concise description of what you want to happen. + +### Describe alternatives you've considered +A clear and concise description of any alternative solutions or features you've considered. + +### Additional context +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md index 951afbf02c..2ee2fb6f39 100644 --- a/.github/ISSUE_TEMPLATE/other.md +++ b/.github/ISSUE_TEMPLATE/other.md @@ -2,6 +2,7 @@ name: Other about: Noticed a bug, having a question or a feature request? title: '' +labels: '' assignees: '' --- diff --git a/BenchmarkTests/DataStorage/LoggingStorageBenchmarkTests.swift b/BenchmarkTests/DataStorage/LoggingStorageBenchmarkTests.swift index 689050f0d1..d8df24edf0 100644 --- a/BenchmarkTests/DataStorage/LoggingStorageBenchmarkTests.swift +++ b/BenchmarkTests/DataStorage/LoggingStorageBenchmarkTests.swift @@ -71,7 +71,7 @@ class LoggingStorageBenchmarkTests: XCTestCase { measureMetrics([.wallClockTime], automaticallyStartMeasuring: false) { self.startMeasuring() - let batch = reader.readNextBatch() + let batch = reader.readNextBatches(1).first self.stopMeasuring() XCTAssertNotNil(batch, "Not enough batch files were created for this benchmark.") @@ -101,6 +101,7 @@ class LoggingStorageBenchmarkTests: XCTestCase { threadName: "main", applicationVersion: "0.0.0", applicationBuildNumber: "0", + buildId: "0", dd: .init(device: .init(architecture: "testArch")), os: .init( name: "OS", diff --git a/BenchmarkTests/DataStorage/RUMStorageBenchmarkTests.swift b/BenchmarkTests/DataStorage/RUMStorageBenchmarkTests.swift index 474018e2e9..acc09206ce 100644 --- a/BenchmarkTests/DataStorage/RUMStorageBenchmarkTests.swift +++ b/BenchmarkTests/DataStorage/RUMStorageBenchmarkTests.swift @@ -71,7 +71,7 @@ class RUMStorageBenchmarkTests: XCTestCase { measureMetrics([.wallClockTime], automaticallyStartMeasuring: false) { self.startMeasuring() - let batch = reader.readNextBatch() + let batch = reader.readNextBatches(1).first self.stopMeasuring() XCTAssertNotNil(batch, "Not enough batch files were created for this benchmark.") @@ -82,3 +82,9 @@ class RUMStorageBenchmarkTests: XCTestCase { } } } + +extension Reader { + func readNextBatches(_ limit: Int = .max) -> [Batch] { + return readFiles(limit: limit).compactMap { readBatch(from: $0) } + } +} diff --git a/BenchmarkTests/DataStorage/TracingStorageBenchmarkTests.swift b/BenchmarkTests/DataStorage/TracingStorageBenchmarkTests.swift index a8fb4b9da9..5a4c33b858 100644 --- a/BenchmarkTests/DataStorage/TracingStorageBenchmarkTests.swift +++ b/BenchmarkTests/DataStorage/TracingStorageBenchmarkTests.swift @@ -71,7 +71,7 @@ class TracingStorageBenchmarkTests: XCTestCase { measureMetrics([.wallClockTime], automaticallyStartMeasuring: false) { self.startMeasuring() - let batch = reader.readNextBatch() + let batch = reader.readNextBatches(1).first self.stopMeasuring() XCTAssertNotNil(batch, "Not enough batch files were created for this benchmark.") diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f4eefaf4..7fa0f77b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # Unreleased +# 2.6.0 / 09-01-2024 +- [FEATURE] Add `currentSessionID(completion:)` accessor to access the current session ID. +- [FEATURE] Add `BatchProcessingLevel` configuration allowing to process more batches within single read/upload cycle. See [#1531][] +- [FIX] Use `currentRequest` instead `originalRequest` for URLSession request interception +- [FIX] Remove weak `UIViewController` references. See [#1597][] + # 2.5.1 / 20-12-2023 - [BUGFIX] Fix `view.time_spent` in RUM view events. See [#1596][] +- [FEATURE] Start RUM session on RUM init. See [#1594][] + # 2.5.0 / 08-11-2023 - [BUGFIX] Optimize Session Replay diffing algorithm. See [#1524][] @@ -560,8 +568,11 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#1524]: https://github.com/DataDog/dd-sdk-ios/pull/1524 [#1529]: https://github.com/DataDog/dd-sdk-ios/pull/1529 [#1533]: https://github.com/DataDog/dd-sdk-ios/pull/1533 +[#1594]: https://github.com/DataDog/dd-sdk-ios/pull/1594 [#1536]: https://github.com/DataDog/dd-sdk-ios/pull/1536 +[#1531]: https://github.com/DataDog/dd-sdk-ios/pull/1531 [#1596]: https://github.com/DataDog/dd-sdk-ios/pull/1596 +[#1597]: https://github.com/DataDog/dd-sdk-ios/pull/1597 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin [@hengyu]: https://github.com/Hengyu diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 9d388f3eb3..7179b4a3a7 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -23,16 +23,10 @@ 3C0D5DF62A5443B100446CF9 /* DataFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0D5DF42A5443B100446CF9 /* DataFormatTests.swift */; }; 3C1890152ABDE9BF00CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C1890132ABDE99200CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m */; }; 3C1890162ABDE9C000CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C1890132ABDE99200CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m */; }; - 3C2206F32AB9CE9300DE780C /* MetaTypeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2206F22AB9CE9300DE780C /* MetaTypeExtensions.swift */; }; - 3C2206F42AB9CE9300DE780C /* MetaTypeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2206F22AB9CE9300DE780C /* MetaTypeExtensions.swift */; }; 3C2206F52AB9DB9000DE780C /* DatadogSessionReplay.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6133D1F52A6ED9E100384BEF /* DatadogSessionReplay.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C2206F62AB9DBA700DE780C /* DatadogRUM.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D29A9F3429DD84AA005C54A4 /* DatadogRUM.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C2206F72AB9DBB600DE780C /* DatadogTrace.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D25EE93429C4C3C300CE3839 /* DatadogTrace.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3C2206F82AB9DBC600DE780C /* DatadogInternal.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D23039A5298D513C001A1FA3 /* DatadogInternal.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3C394EF72AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C394EF62AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift */; }; - 3C394EF82AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C394EF62AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift */; }; - 3C394EFA2AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C394EF92AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift */; }; - 3C394EFB2AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C394EF92AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift */; }; 3C41693C29FBF4D50042B9D2 /* DatadogWebViewTracking.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CE119FE29F7BE0100202522 /* DatadogWebViewTracking.framework */; }; 3C74305C29FBC0480053B80F /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2DA2385298D57AA00C6C7E6 /* DatadogInternal.framework */; }; 3C85D42129F7C5C900AFF894 /* WebViewTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C85D41429F7C59C00AFF894 /* WebViewTracking.swift */; }; @@ -40,22 +34,8 @@ 3C85D42C29F7C87D00AFF894 /* HostsSanitizerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C85D42B29F7C87D00AFF894 /* HostsSanitizerMock.swift */; }; 3C85D42D29F7C87D00AFF894 /* HostsSanitizerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C85D42B29F7C87D00AFF894 /* HostsSanitizerMock.swift */; }; 3C9C6BB429F7C0C000581C43 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D23039A5298D513C001A1FA3 /* DatadogInternal.framework */; }; - 3CB32AD42ACB733000D602ED /* URLSessionSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB32AD32ACB733000D602ED /* URLSessionSwizzler.swift */; }; - 3CB32AD52ACB733000D602ED /* URLSessionSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB32AD32ACB733000D602ED /* URLSessionSwizzler.swift */; }; - 3CB32AD72ACB735600D602ED /* URLSessionSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB32AD62ACB735600D602ED /* URLSessionSwizzlerTests.swift */; }; - 3CB32AD82ACB735600D602ED /* URLSessionSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB32AD62ACB735600D602ED /* URLSessionSwizzlerTests.swift */; }; - 3CBDE66E2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE66D2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift */; }; - 3CBDE66F2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE66D2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift */; }; - 3CBDE6712AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6702AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift */; }; - 3CBDE6722AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6702AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift */; }; 3CBDE6742AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6732AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift */; }; 3CBDE6752AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6732AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift */; }; - 3CBDE6812AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6802AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift */; }; - 3CBDE6822AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6802AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift */; }; - 3CBDE6842AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6832AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift */; }; - 3CBDE6852AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6832AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift */; }; - 3CBDE6872AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6862AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift */; }; - 3CBDE6882AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6862AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift */; }; 3CBDE68A2AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6892AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift */; }; 3CBDE68B2AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBDE6892AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift */; }; 3CCCA5C42ABAF0F80029D7BD /* DDURLSessionInstrumentation+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCCA5C32ABAF0F80029D7BD /* DDURLSessionInstrumentation+objc.swift */; }; @@ -64,8 +44,6 @@ 3CCCA5C82ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */; }; 3CE11A1129F7BE0900202522 /* DatadogWebViewTracking.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CE119FE29F7BE0100202522 /* DatadogWebViewTracking.framework */; }; 3CE11A1229F7BE0900202522 /* DatadogWebViewTracking.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3CE119FE29F7BE0100202522 /* DatadogWebViewTracking.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3CFD81952ABBB66400977C22 /* MetaTypeExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFD81942ABBB66400977C22 /* MetaTypeExtensionsTests.swift */; }; - 3CFD81962ABBB66400977C22 /* MetaTypeExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFD81942ABBB66400977C22 /* MetaTypeExtensionsTests.swift */; }; 49274906288048B500ECD49B /* InternalProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49274903288048AA00ECD49B /* InternalProxyTests.swift */; }; 49274907288048B800ECD49B /* InternalProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49274903288048AA00ECD49B /* InternalProxyTests.swift */; }; 49D8C0B72AC5D2160075E427 /* RUM+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D8C0B62AC5D2160075E427 /* RUM+Internal.swift */; }; @@ -74,11 +52,8 @@ 49D8C0BE2AC5F2BC0075E427 /* Logs+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49D8C0B92AC5F21F0075E427 /* Logs+Internal.swift */; }; 61020C2A2757AD91005EEAEA /* BackgroundLocationMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61020C292757AD91005EEAEA /* BackgroundLocationMonitor.swift */; }; 61020C2C2758E853005EEAEA /* DebugBackgroundEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61020C2B2758E853005EEAEA /* DebugBackgroundEventsViewController.swift */; }; - 61054E5E2A6EE10A00AAA894 /* EnrichedRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E052A6EE10A00AAA894 /* EnrichedRecord.swift */; }; - 61054E5F2A6EE10A00AAA894 /* SRDataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E062A6EE10A00AAA894 /* SRDataModels.swift */; }; - 61054E602A6EE10A00AAA894 /* SRDataModels+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E072A6EE10A00AAA894 /* SRDataModels+UIKit.swift */; }; 61054E612A6EE10A00AAA894 /* SRCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E082A6EE10A00AAA894 /* SRCompression.swift */; }; - 61054E622A6EE10A00AAA894 /* Writer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E092A6EE10A00AAA894 /* Writer.swift */; }; + 61054E622A6EE10A00AAA894 /* RecordWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E092A6EE10A00AAA894 /* RecordWriter.swift */; }; 61054E632A6EE10A00AAA894 /* SessionReplayConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */; }; 61054E642A6EE10A00AAA894 /* SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */; }; 61054E652A6EE10A00AAA894 /* AppWindowObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E0F2A6EE10A00AAA894 /* AppWindowObserver.swift */; }; @@ -122,7 +97,7 @@ 61054E8B2A6EE10A00AAA894 /* SessionReplayFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E3C2A6EE10A00AAA894 /* SessionReplayFeature.swift */; }; 61054E8D2A6EE10A00AAA894 /* RUMContextReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */; }; 61054E8E2A6EE10A00AAA894 /* SRContextPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */; }; - 61054E8F2A6EE10A00AAA894 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E412A6EE10A00AAA894 /* RequestBuilder.swift */; }; + 61054E8F2A6EE10A00AAA894 /* SegmentRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E412A6EE10A00AAA894 /* SegmentRequestBuilder.swift */; }; 61054E902A6EE10A00AAA894 /* SegmentJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E432A6EE10A00AAA894 /* SegmentJSON.swift */; }; 61054E912A6EE10A00AAA894 /* EnrichedRecordJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E442A6EE10A00AAA894 /* EnrichedRecordJSON.swift */; }; 61054E922A6EE10A00AAA894 /* SegmentJSONBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E452A6EE10A00AAA894 /* SegmentJSONBuilder.swift */; }; @@ -152,7 +127,7 @@ 61054F9C2A6EE1BA00AAA894 /* SwiftExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F452A6EE1B900AAA894 /* SwiftExtensionsTests.swift */; }; 61054F9D2A6EE1BA00AAA894 /* MainThreadSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F472A6EE1B900AAA894 /* MainThreadSchedulerTests.swift */; }; 61054F9E2A6EE1BA00AAA894 /* SessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */; }; - 61054F9F2A6EE1BA00AAA894 /* WriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F4A2A6EE1BA00AAA894 /* WriterTests.swift */; }; + 61054F9F2A6EE1BA00AAA894 /* RecordsWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F4A2A6EE1BA00AAA894 /* RecordsWriterTests.swift */; }; 61054FA02A6EE1BA00AAA894 /* SRCompressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F4B2A6EE1BA00AAA894 /* SRCompressionTests.swift */; }; 61054FA12A6EE1BA00AAA894 /* EnrichedRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F4D2A6EE1BA00AAA894 /* EnrichedRecordTests.swift */; }; 61054FA22A6EE1BA00AAA894 /* TextObfuscatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F502A6EE1BA00AAA894 /* TextObfuscatorTests.swift */; }; @@ -204,7 +179,7 @@ 61054FD12A6EE1BA00AAA894 /* SegmentJSONBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F8D2A6EE1BA00AAA894 /* SegmentJSONBuilderTests.swift */; }; 61054FD22A6EE1BA00AAA894 /* EnrichedRecordJSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F8E2A6EE1BA00AAA894 /* EnrichedRecordJSONTests.swift */; }; 61054FD32A6EE1BA00AAA894 /* MultipartFormDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F902A6EE1BA00AAA894 /* MultipartFormDataTests.swift */; }; - 61054FD42A6EE1BA00AAA894 /* RequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F912A6EE1BA00AAA894 /* RequestBuilderTests.swift */; }; + 61054FD42A6EE1BA00AAA894 /* SegmentRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F912A6EE1BA00AAA894 /* SegmentRequestBuilderTests.swift */; }; 61054FD52A6EE1BA00AAA894 /* XCTAssertRectsEqual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F932A6EE1BA00AAA894 /* XCTAssertRectsEqual.swift */; }; 610ABD4C2A6930CA00AFEA34 /* TelemetryCoreIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610ABD4B2A6930CA00AFEA34 /* TelemetryCoreIntegrationTests.swift */; }; 610ABD4D2A6930CA00AFEA34 /* TelemetryCoreIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610ABD4B2A6930CA00AFEA34 /* TelemetryCoreIntegrationTests.swift */; }; @@ -471,6 +446,8 @@ 61E45BE724519A3700F2C652 /* JSONDataMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E45BE624519A3700F2C652 /* JSONDataMatcher.swift */; }; 61E45ED12451A8730061DAC7 /* SpanMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E45ED02451A8730061DAC7 /* SpanMatcher.swift */; }; 61E5333824B84EE2003D6C4E /* DebugRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */; }; + 61E8C5082B28898800E709B4 /* StartingRUMSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E8C5072B28898800E709B4 /* StartingRUMSessionTests.swift */; }; + 61E8C5092B28898800E709B4 /* StartingRUMSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E8C5072B28898800E709B4 /* StartingRUMSessionTests.swift */; }; 61E95D882695C00200EA3115 /* DDCrashReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E95D872695C00200EA3115 /* DDCrashReportExporterTests.swift */; }; 61ED39D426C2A36B002C0F26 /* DataUploadStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61ED39D326C2A36B002C0F26 /* DataUploadStatus.swift */; }; 61EF78C1257F842000EDCCB3 /* FeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61EF78C0257F842000EDCCB3 /* FeatureTests.swift */; }; @@ -500,10 +477,21 @@ 9EE5AD8226205B82001E699E /* DDNSURLSessionDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */; }; A70A82652A935F210072F5DC /* BackgroundTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70A82642A935F210072F5DC /* BackgroundTaskCoordinator.swift */; }; A70A82662A935F210072F5DC /* BackgroundTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70A82642A935F210072F5DC /* BackgroundTaskCoordinator.swift */; }; + A71013D62B178FAD00101E60 /* ResourcesWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71013D52B178FAD00101E60 /* ResourcesWriterTests.swift */; }; + A71265862B17980C007D63CE /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; + A712658F2B179C94007D63CE /* EnrichedResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A712658B2B179C93007D63CE /* EnrichedResource.swift */; }; + A71265902B179C94007D63CE /* SRDataModels+UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A712658C2B179C93007D63CE /* SRDataModels+UIKit.swift */; }; + A71265912B179C94007D63CE /* SRDataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A712658D2B179C93007D63CE /* SRDataModels.swift */; }; + A71265922B179C94007D63CE /* EnrichedRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A712658E2B179C93007D63CE /* EnrichedRecord.swift */; }; A728ADAB2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = A728ADAA2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift */; }; A728ADAC2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = A728ADAA2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift */; }; A728ADB02934EB0900397996 /* DDW3CHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A728ADAD2934EB0300397996 /* DDW3CHTTPHeadersWriter+apiTests.m */; }; A728ADB12934EB0C00397996 /* DDW3CHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A728ADAD2934EB0300397996 /* DDW3CHTTPHeadersWriter+apiTests.m */; }; + A73A54982B16406900E1F7E3 /* ResourcesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A73A54972B16406900E1F7E3 /* ResourcesFeature.swift */; }; + A74A72812B0CEE4900771FEB /* ResourceRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74A72802B0CEE4900771FEB /* ResourceRequestBuilder.swift */; }; + A74A72852B10CC6700771FEB /* ResourceRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74A72842B10CC6700771FEB /* ResourceRequestBuilderTests.swift */; }; + A74A72872B10CE4100771FEB /* ResourceMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74A72862B10CE4100771FEB /* ResourceMocks.swift */; }; + A74A72892B10D95D00771FEB /* MultipartBuilderSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74A72882B10D95D00771FEB /* MultipartBuilderSpy.swift */; }; A79B0F64292BD074008742B3 /* DDB3HTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A79B0F63292BD074008742B3 /* DDB3HTTPHeadersWriter+apiTests.m */; }; A79B0F65292BD074008742B3 /* DDB3HTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A79B0F63292BD074008742B3 /* DDB3HTTPHeadersWriter+apiTests.m */; }; A79B0F66292BD7CA008742B3 /* B3HTTPHeadersWriter+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79B0F5E292BA435008742B3 /* B3HTTPHeadersWriter+objc.swift */; }; @@ -514,6 +502,7 @@ A7DA18052AB0C91300F76337 /* DDUIKitRUMViewsPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DA18022AB0C8A700F76337 /* DDUIKitRUMViewsPredicateTests.swift */; }; A7DA18072AB0CA5E00F76337 /* DDUIKitRUMActionsPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */; }; A7EA11622AB0CE6C00C73970 /* DDUIKitRUMActionsPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */; }; + A7EA88562B17639A00FE2580 /* ResourcesWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7EA88552B17639A00FE2580 /* ResourcesWriter.swift */; }; D20605A3287464F40047275C /* ContextValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A2287464F40047275C /* ContextValuePublisher.swift */; }; D20605A4287464F40047275C /* ContextValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A2287464F40047275C /* ContextValuePublisher.swift */; }; D20605A6287476230047275C /* ServerOffsetPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A5287476230047275C /* ServerOffsetPublisher.swift */; }; @@ -607,6 +596,8 @@ D2160CF529C0EDFC00FAA9A5 /* UploadPerformancePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = D26C49B52889416300802B2D /* UploadPerformancePreset.swift */; }; D2160CF729C0EE2B00FAA9A5 /* UploadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2160CF629C0EE2B00FAA9A5 /* UploadMocks.swift */; }; D2160CF829C0EE2B00FAA9A5 /* UploadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2160CF629C0EE2B00FAA9A5 /* UploadMocks.swift */; }; + D2181A8E2B051B7900A518C0 /* URLSessionSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2181A8D2B051B7900A518C0 /* URLSessionSwizzlerTests.swift */; }; + D2181A8F2B051B7900A518C0 /* URLSessionSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2181A8D2B051B7900A518C0 /* URLSessionSwizzlerTests.swift */; }; D21AE6BC29E5EDAF0064BF29 /* TelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21AE6BB29E5EDAF0064BF29 /* TelemetryTests.swift */; }; D21AE6BD29E5EDAF0064BF29 /* TelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21AE6BB29E5EDAF0064BF29 /* TelemetryTests.swift */; }; D21C26C528A3B49C005DD405 /* FeatureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26C428A3B49C005DD405 /* FeatureStorage.swift */; }; @@ -730,7 +721,7 @@ D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26D294395A300134409 /* RUMContextAttributes.swift */; }; D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333524B84B43003D6C4E /* RUMMonitor.swift */; }; D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6156CB8D24DDA1B5008CB2B2 /* RUMContextProvider.swift */; }; - D23F8E6D29DDCD28001CFAE8 /* RUMViewIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* RUMViewIdentity.swift */; }; + D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */; }; D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EFF3D22731822A00D09F33 /* RUMViewsHandler.swift */; }; D23F8E6F29DDCD28001CFAE8 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2ED29CC73240063802D /* RequestBuilder.swift */; }; D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB11E29D30AF000737A9A /* URLSessionRUMResourcesHandler.swift */; }; @@ -784,7 +775,7 @@ D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; D23F8EB829DDCD38001CFAE8 /* UIKitRUMUserActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */; }; D23F8EB929DDCD38001CFAE8 /* RUMFeatureMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333024B75DFC003D6C4E /* RUMFeatureMocks.swift */; }; - D23F8EBA29DDCD38001CFAE8 /* RUMViewIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* RUMViewIdentityTests.swift */; }; + D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */; }; D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */; }; D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB12129D34A5F00737A9A /* URLSessionRUMResourcesHandlerTests.swift */; }; D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122EED25B1D75B00F9C7F5 /* RUMEventSanitizerTests.swift */; }; @@ -908,6 +899,10 @@ D26C49C0288982DA00802B2D /* FeatureUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D26C49BE288982DA00802B2D /* FeatureUpload.swift */; }; D26F741129ACBDA100D25622 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D23039A5298D513C001A1FA3 /* DatadogInternal.framework */; }; D26F741229ACBDAD00D25622 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2DA2385298D57AA00C6C7E6 /* DatadogInternal.framework */; }; + D270CDDD2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */; }; + D270CDDE2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */; }; + D270CDE02B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */; }; + D270CDE12B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */; }; D2777D9D29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; D2777D9E29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; D2790C7229DEFCF400D88DA9 /* RUMDataModelMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22743E829DEC9A9001A7EF9 /* RUMDataModelMocks.swift */; }; @@ -956,7 +951,7 @@ D29A9F5C29DD85BB005C54A4 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; D29A9F5D29DD85BB005C54A4 /* RUMCommandSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */; }; D29A9F5E29DD85BB005C54A4 /* UIKitRUMViewsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */; }; - D29A9F6029DD85BB005C54A4 /* RUMViewIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* RUMViewIdentity.swift */; }; + D29A9F6029DD85BB005C54A4 /* ViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */; }; D29A9F6129DD85BB005C54A4 /* CrashReportReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D236BE2729520FED00676E67 /* CrashReportReceiver.swift */; }; D29A9F6229DD85BB005C54A4 /* WebViewEventReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26A294383F200134409 /* WebViewEventReceiver.swift */; }; D29A9F6329DD85BB005C54A4 /* RUMMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333524B84B43003D6C4E /* RUMMonitor.swift */; }; @@ -1017,7 +1012,7 @@ D29A9FAE29DDB483005C54A4 /* SessionReplayDependencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615950EA291C029700470E0C /* SessionReplayDependencyTests.swift */; }; D29A9FB029DDB483005C54A4 /* RUMOperatingSystemInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C0AA028573F6300C13264 /* RUMOperatingSystemInfoTests.swift */; }; D29A9FB329DDB483005C54A4 /* RUMScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFDE24C75FD300589570 /* RUMScopeTests.swift */; }; - D29A9FB729DDB483005C54A4 /* RUMViewIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* RUMViewIdentityTests.swift */; }; + D29A9FB729DDB483005C54A4 /* ViewIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */; }; D29A9FB829DDB483005C54A4 /* RUMViewScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */; }; D29A9FB929DDB483005C54A4 /* RUMEventsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E81F625A743600084B751 /* RUMEventsMapperTests.swift */; }; D29A9FBB29DDB483005C54A4 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; @@ -1099,6 +1094,18 @@ 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 */; }; + D2BEEDAC2B3356710065F3AC /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDAB2B3356710065F3AC /* URLSessionTaskSwizzler.swift */; }; + D2BEEDAD2B3356710065F3AC /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDAB2B3356710065F3AC /* URLSessionTaskSwizzler.swift */; }; + D2BEEDAF2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDAE2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift */; }; + D2BEEDB02B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDAE2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift */; }; + D2BEEDB22B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB12B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift */; }; + D2BEEDB32B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB12B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift */; }; + D2BEEDB52B3360820065F3AC /* URLSessionSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB42B33607D0065F3AC /* URLSessionSwizzler.swift */; }; + D2BEEDB62B3360830065F3AC /* URLSessionSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB42B33607D0065F3AC /* URLSessionSwizzler.swift */; }; + D2BEEDB82B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB72B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift */; }; + D2BEEDB92B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BEEDB72B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift */; }; + D2BEEDBA2B33638F0065F3AC /* NetworkInstrumentationSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2181A8A2B0500BB00A518C0 /* NetworkInstrumentationSwizzler.swift */; }; + D2BEEDBB2B3363900065F3AC /* NetworkInstrumentationSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2181A8A2B0500BB00A518C0 /* NetworkInstrumentationSwizzler.swift */; }; D2C1A4FA29C4C4CB00946C31 /* SpanSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122ECD25B1B74500F9C7F5 /* SpanSanitizer.swift */; }; D2C1A4FB29C4C4CB00946C31 /* MessageReceivers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2546C0A29AF56270054E00B /* MessageReceivers.swift */; }; D2C1A4FC29C4C4CB00946C31 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2546C0729AF55E90054E00B /* RequestBuilder.swift */; }; @@ -1868,36 +1875,22 @@ 3C0D5DEE2A5442A900446CF9 /* EventMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMocks.swift; sourceTree = ""; }; 3C0D5DF42A5443B100446CF9 /* DataFormatTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataFormatTests.swift; sourceTree = ""; }; 3C1890132ABDE99200CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDURLSessionInstrumentationTests+apiTests.m"; sourceTree = ""; }; - 3C2206F22AB9CE9300DE780C /* MetaTypeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaTypeExtensions.swift; sourceTree = ""; }; - 3C394EF62AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = ""; }; - 3C394EF92AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = ""; }; 3C85D41429F7C59C00AFF894 /* WebViewTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewTracking.swift; sourceTree = ""; }; 3C85D42B29F7C87D00AFF894 /* HostsSanitizerMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HostsSanitizerMock.swift; sourceTree = ""; }; - 3CB32AD32ACB733000D602ED /* URLSessionSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionSwizzler.swift; sourceTree = ""; }; - 3CB32AD62ACB735600D602ED /* URLSessionSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionSwizzlerTests.swift; sourceTree = ""; }; - 3CBDE66D2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskDelegateSwizzler.swift; sourceTree = ""; }; - 3CBDE6702AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzler.swift; sourceTree = ""; }; 3CBDE6732AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionInstrumentation.swift; sourceTree = ""; }; - 3CBDE6802AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskDelegateSwizzlerTests.swift; sourceTree = ""; }; - 3CBDE6832AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzlerTests.swift; sourceTree = ""; }; - 3CBDE6862AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionTaskDelegate+Tracking.swift"; sourceTree = ""; }; 3CBDE6892AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionTask+Tracking.swift"; sourceTree = ""; }; 3CCCA5C32ABAF0F80029D7BD /* DDURLSessionInstrumentation+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DDURLSessionInstrumentation+objc.swift"; sourceTree = ""; }; 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDURLSessionInstrumentationConfigurationTests.swift; sourceTree = ""; }; 3CE119FE29F7BE0100202522 /* DatadogWebViewTracking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogWebViewTracking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3CE11A0529F7BE0300202522 /* DatadogWebViewTrackingTests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogWebViewTrackingTests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 3CFD81942ABBB66400977C22 /* MetaTypeExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaTypeExtensionsTests.swift; sourceTree = ""; }; 49274903288048AA00ECD49B /* InternalProxyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InternalProxyTests.swift; sourceTree = ""; }; 49274908288048F400ECD49B /* RUMInternalProxyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RUMInternalProxyTests.swift; sourceTree = ""; }; 49D8C0B62AC5D2160075E427 /* RUM+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RUM+Internal.swift"; sourceTree = ""; }; 49D8C0B92AC5F21F0075E427 /* Logs+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logs+Internal.swift"; sourceTree = ""; }; 61020C292757AD91005EEAEA /* BackgroundLocationMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundLocationMonitor.swift; sourceTree = ""; }; 61020C2B2758E853005EEAEA /* DebugBackgroundEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugBackgroundEventsViewController.swift; sourceTree = ""; }; - 61054E052A6EE10A00AAA894 /* EnrichedRecord.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnrichedRecord.swift; sourceTree = ""; }; - 61054E062A6EE10A00AAA894 /* SRDataModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRDataModels.swift; sourceTree = ""; }; - 61054E072A6EE10A00AAA894 /* SRDataModels+UIKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SRDataModels+UIKit.swift"; sourceTree = ""; }; 61054E082A6EE10A00AAA894 /* SRCompression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRCompression.swift; sourceTree = ""; }; - 61054E092A6EE10A00AAA894 /* Writer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Writer.swift; sourceTree = ""; }; + 61054E092A6EE10A00AAA894 /* RecordWriter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordWriter.swift; sourceTree = ""; }; 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayConfiguration.swift; sourceTree = ""; }; 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplay.swift; sourceTree = ""; }; 61054E0F2A6EE10A00AAA894 /* AppWindowObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppWindowObserver.swift; sourceTree = ""; }; @@ -1941,7 +1934,7 @@ 61054E3C2A6EE10A00AAA894 /* SessionReplayFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayFeature.swift; sourceTree = ""; }; 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RUMContextReceiver.swift; sourceTree = ""; }; 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRContextPublisher.swift; sourceTree = ""; }; - 61054E412A6EE10A00AAA894 /* RequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestBuilder.swift; sourceTree = ""; }; + 61054E412A6EE10A00AAA894 /* SegmentRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRequestBuilder.swift; sourceTree = ""; }; 61054E432A6EE10A00AAA894 /* SegmentJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentJSON.swift; sourceTree = ""; }; 61054E442A6EE10A00AAA894 /* EnrichedRecordJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnrichedRecordJSON.swift; sourceTree = ""; }; 61054E452A6EE10A00AAA894 /* SegmentJSONBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentJSONBuilder.swift; sourceTree = ""; }; @@ -1971,7 +1964,7 @@ 61054F452A6EE1B900AAA894 /* SwiftExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftExtensionsTests.swift; sourceTree = ""; }; 61054F472A6EE1B900AAA894 /* MainThreadSchedulerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainThreadSchedulerTests.swift; sourceTree = ""; }; 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayTests.swift; sourceTree = ""; }; - 61054F4A2A6EE1BA00AAA894 /* WriterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WriterTests.swift; sourceTree = ""; }; + 61054F4A2A6EE1BA00AAA894 /* RecordsWriterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordsWriterTests.swift; sourceTree = ""; }; 61054F4B2A6EE1BA00AAA894 /* SRCompressionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRCompressionTests.swift; sourceTree = ""; }; 61054F4D2A6EE1BA00AAA894 /* EnrichedRecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnrichedRecordTests.swift; sourceTree = ""; }; 61054F502A6EE1BA00AAA894 /* TextObfuscatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextObfuscatorTests.swift; sourceTree = ""; }; @@ -2023,7 +2016,7 @@ 61054F8D2A6EE1BA00AAA894 /* SegmentJSONBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentJSONBuilderTests.swift; sourceTree = ""; }; 61054F8E2A6EE1BA00AAA894 /* EnrichedRecordJSONTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnrichedRecordJSONTests.swift; sourceTree = ""; }; 61054F902A6EE1BA00AAA894 /* MultipartFormDataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultipartFormDataTests.swift; sourceTree = ""; }; - 61054F912A6EE1BA00AAA894 /* RequestBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestBuilderTests.swift; sourceTree = ""; }; + 61054F912A6EE1BA00AAA894 /* SegmentRequestBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRequestBuilderTests.swift; sourceTree = ""; }; 61054F932A6EE1BA00AAA894 /* XCTAssertRectsEqual.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTAssertRectsEqual.swift; sourceTree = ""; }; 610ABD4B2A6930CA00AFEA34 /* TelemetryCoreIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryCoreIntegrationTests.swift; sourceTree = ""; }; 61112F8D2A4417D6006FFCA6 /* DDRUM+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDRUM+apiTests.m"; sourceTree = ""; }; @@ -2285,7 +2278,7 @@ 61BAD46926415FCE001886CA /* OTSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTSpanTests.swift; sourceTree = ""; }; 61BB2B1A244A185D009F3F56 /* PerformancePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformancePreset.swift; sourceTree = ""; }; 61BBD19624ED50040023E65F /* DatadogConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogConfigurationTests.swift; sourceTree = ""; }; - 61C1510C25AC8C1B00362D4B /* RUMViewIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMViewIdentityTests.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 = ""; }; 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionScopeTests.swift; sourceTree = ""; }; 61C2C21124C5951400C0321C /* RUMViewScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMViewScope.swift; sourceTree = ""; }; @@ -2354,6 +2347,7 @@ 61E5333024B75DFC003D6C4E /* RUMFeatureMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMFeatureMocks.swift; sourceTree = ""; }; 61E5333524B84B43003D6C4E /* RUMMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMMonitor.swift; sourceTree = ""; }; 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugRUMViewController.swift; sourceTree = ""; }; + 61E8C5072B28898800E709B4 /* StartingRUMSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartingRUMSessionTests.swift; sourceTree = ""; }; 61E909E624A24DD3005EA2DE /* OTSpan.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OTSpan.swift; sourceTree = ""; }; 61E909E724A24DD3005EA2DE /* OTFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OTFormat.swift; sourceTree = ""; }; 61E909E924A24DD3005EA2DE /* OTTracer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OTTracer.swift; sourceTree = ""; }; @@ -2389,7 +2383,7 @@ 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMEventMatcher.swift; sourceTree = ""; }; 61FF282F24BC5E2D000B3D9B /* RUMEventFileOutputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMEventFileOutputTests.swift; sourceTree = ""; }; 61FF416125EE5FF400CE35EC /* CrashLogReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashLogReceiverTests.swift; sourceTree = ""; }; - 61FF9A4425AC5DEA001058CC /* RUMViewIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMViewIdentity.swift; sourceTree = ""; }; + 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifier.swift; sourceTree = ""; }; 9E0542CA25F8EBBE007A3D0B /* Kronos.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Kronos.xcframework; path = ../Carthage/Build/Kronos.xcframework; sourceTree = ""; }; 9E26E6B824C87693000B3270 /* RUMDataModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RUMDataModels.swift; sourceTree = ""; }; 9E2EF44E2694FA14008A7DAE /* VitalInfoSamplerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VitalInfoSamplerTests.swift; sourceTree = ""; }; @@ -2413,6 +2407,12 @@ 9EC8B5ED2668E4DB000F7529 /* VitalCPUReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VitalCPUReaderTests.swift; sourceTree = ""; }; 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDNSURLSessionDelegateTests.swift; sourceTree = ""; }; A70A82642A935F210072F5DC /* BackgroundTaskCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskCoordinator.swift; sourceTree = ""; }; + A71013D52B178FAD00101E60 /* ResourcesWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesWriterTests.swift; sourceTree = ""; }; + A71265852B17980C007D63CE /* MockFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeature.swift; sourceTree = ""; }; + A712658B2B179C93007D63CE /* EnrichedResource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EnrichedResource.swift; path = ../../Models/EnrichedResource.swift; sourceTree = ""; }; + A712658C2B179C93007D63CE /* SRDataModels+UIKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "SRDataModels+UIKit.swift"; path = "../../Models/SRDataModels+UIKit.swift"; sourceTree = ""; }; + A712658D2B179C93007D63CE /* SRDataModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SRDataModels.swift; path = ../../Models/SRDataModels.swift; sourceTree = ""; }; + A712658E2B179C93007D63CE /* EnrichedRecord.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EnrichedRecord.swift; path = ../../Models/EnrichedRecord.swift; sourceTree = ""; }; A728AD9C2934CE4400397996 /* W3CHTTPHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = W3CHTTPHeaders.swift; sourceTree = ""; }; A728AD9E2934CE5000397996 /* W3CHTTPHeadersWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = W3CHTTPHeadersWriter.swift; sourceTree = ""; }; A728ADA02934CE5D00397996 /* W3CHTTPHeadersReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = W3CHTTPHeadersReader.swift; sourceTree = ""; }; @@ -2420,6 +2420,11 @@ A728ADA52934DF2400397996 /* W3CHTTPHeadersReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = W3CHTTPHeadersReaderTests.swift; sourceTree = ""; }; A728ADAA2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "W3CHTTPHeadersWriter+objc.swift"; sourceTree = ""; }; A728ADAD2934EB0300397996 /* DDW3CHTTPHeadersWriter+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDW3CHTTPHeadersWriter+apiTests.m"; sourceTree = ""; }; + A73A54972B16406900E1F7E3 /* ResourcesFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesFeature.swift; sourceTree = ""; }; + A74A72802B0CEE4900771FEB /* ResourceRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResourceRequestBuilder.swift; sourceTree = ""; }; + A74A72842B10CC6700771FEB /* ResourceRequestBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceRequestBuilderTests.swift; sourceTree = ""; }; + A74A72862B10CE4100771FEB /* ResourceMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceMocks.swift; sourceTree = ""; }; + A74A72882B10D95D00771FEB /* MultipartBuilderSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartBuilderSpy.swift; sourceTree = ""; }; A79B0F5A292B7C06008742B3 /* B3HTTPHeadersWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B3HTTPHeadersWriterTests.swift; sourceTree = ""; }; A79B0F5E292BA435008742B3 /* B3HTTPHeadersWriter+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "B3HTTPHeadersWriter+objc.swift"; sourceTree = ""; }; A79B0F60292BB071008742B3 /* B3HTTPHeadersReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B3HTTPHeadersReaderTests.swift; sourceTree = ""; }; @@ -2427,6 +2432,7 @@ A7C816AA2A98CEBA00BF097B /* UIKitBackgroundTaskCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTaskCoordinatorTests.swift; sourceTree = ""; }; A7DA18022AB0C8A700F76337 /* DDUIKitRUMViewsPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDUIKitRUMViewsPredicateTests.swift; sourceTree = ""; }; A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDUIKitRUMActionsPredicateTests.swift; sourceTree = ""; }; + A7EA88552B17639A00FE2580 /* ResourcesWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesWriter.swift; sourceTree = ""; }; A7F773D32924EA2D00AC1A62 /* B3HTTPHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = B3HTTPHeaders.swift; sourceTree = ""; }; A7F773DB29253F8B00AC1A62 /* B3HTTPHeadersWriter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = B3HTTPHeadersWriter.swift; sourceTree = ""; }; A7F773DC29253F8B00AC1A62 /* B3HTTPHeadersReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = B3HTTPHeadersReader.swift; sourceTree = ""; }; @@ -2468,6 +2474,8 @@ D2160CEC29C0E0E600FAA9A5 /* DatadogURLSessionHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatadogURLSessionHandler.swift; sourceTree = ""; }; D2160CEF29C0EC4D00FAA9A5 /* SingleFeatureCoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleFeatureCoreMock.swift; sourceTree = ""; }; D2160CF629C0EE2B00FAA9A5 /* UploadMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadMocks.swift; sourceTree = ""; }; + D2181A8A2B0500BB00A518C0 /* NetworkInstrumentationSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInstrumentationSwizzler.swift; sourceTree = ""; }; + D2181A8D2B051B7900A518C0 /* URLSessionSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionSwizzlerTests.swift; sourceTree = ""; }; D21AE6BB29E5EDAF0064BF29 /* TelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryTests.swift; sourceTree = ""; }; D21C26C428A3B49C005DD405 /* FeatureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureStorage.swift; sourceTree = ""; }; D21C26D028A64599005DD405 /* MessageBusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBusTests.swift; sourceTree = ""; }; @@ -2584,6 +2592,8 @@ D26C49AE2886DC7B00802B2D /* ApplicationStatePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationStatePublisherTests.swift; sourceTree = ""; }; D26C49B52889416300802B2D /* UploadPerformancePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPerformancePreset.swift; sourceTree = ""; }; D26C49BE288982DA00802B2D /* FeatureUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureUpload.swift; sourceTree = ""; }; + D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = ""; }; + D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = ""; }; D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiverTests.swift; sourceTree = ""; }; D286626D2A43487500852CE3 /* Datadog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Datadog.swift; sourceTree = ""; }; D28F836729C9E71C00EF8EA2 /* DDSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSpanTests.swift; sourceTree = ""; }; @@ -2627,6 +2637,11 @@ 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 = ""; }; + D2BEEDAB2B3356710065F3AC /* URLSessionTaskSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzler.swift; sourceTree = ""; }; + D2BEEDAE2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzlerTests.swift; sourceTree = ""; }; + D2BEEDB12B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskDelegateSwizzler.swift; sourceTree = ""; }; + D2BEEDB42B33607D0065F3AC /* URLSessionSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionSwizzler.swift; sourceTree = ""; }; + D2BEEDB72B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskDelegateSwizzlerTests.swift; sourceTree = ""; }; D2C1A51929C4C5DD00946C31 /* JSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEncoder.swift; sourceTree = ""; }; D2C1A55A29C4F2DF00946C31 /* DatadogTrace.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogTrace.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2C1A57329C4F2E800946C31 /* DatadogTraceTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogTraceTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3103,7 +3118,8 @@ 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */, 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, 61054E542A6EE10A00AAA894 /* Utilities */, - 61054E032A6EE10A00AAA894 /* Writer */, + 61054E042A6EE10A00AAA894 /* Models */, + 61054E032A6EE10A00AAA894 /* Writers */, ); name = DatadogSessionReplay; path = ../DatadogSessionReplay/Sources; @@ -3126,24 +3142,26 @@ path = ../DatadogSessionReplay/Tests; sourceTree = ""; }; - 61054E032A6EE10A00AAA894 /* Writer */ = { + 61054E032A6EE10A00AAA894 /* Writers */ = { isa = PBXGroup; children = ( - 61054E042A6EE10A00AAA894 /* Models */, 61054E082A6EE10A00AAA894 /* SRCompression.swift */, - 61054E092A6EE10A00AAA894 /* Writer.swift */, + 61054E092A6EE10A00AAA894 /* RecordWriter.swift */, + A7EA88552B17639A00FE2580 /* ResourcesWriter.swift */, ); - path = Writer; + path = Writers; sourceTree = ""; }; 61054E042A6EE10A00AAA894 /* Models */ = { isa = PBXGroup; children = ( - 61054E052A6EE10A00AAA894 /* EnrichedRecord.swift */, - 61054E062A6EE10A00AAA894 /* SRDataModels.swift */, - 61054E072A6EE10A00AAA894 /* SRDataModels+UIKit.swift */, + A712658E2B179C93007D63CE /* EnrichedRecord.swift */, + A712658B2B179C93007D63CE /* EnrichedResource.swift */, + A712658D2B179C93007D63CE /* SRDataModels.swift */, + A712658C2B179C93007D63CE /* SRDataModels+UIKit.swift */, ); - path = Models; + name = Models; + path = Writers/Models; sourceTree = ""; }; 61054E0D2A6EE10A00AAA894 /* Recorder */ = { @@ -3250,23 +3268,25 @@ 61054E3B2A6EE10A00AAA894 /* Feature */ = { isa = PBXGroup; children = ( + A73A54972B16406900E1F7E3 /* ResourcesFeature.swift */, 61054E3C2A6EE10A00AAA894 /* SessionReplayFeature.swift */, 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */, 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */, D22C5BCD2A98A65D0024CC1F /* Baggages.swift */, - 61054E402A6EE10A00AAA894 /* RequestBuilder */, + 61054E402A6EE10A00AAA894 /* RequestBuilders */, ); path = Feature; sourceTree = ""; }; - 61054E402A6EE10A00AAA894 /* RequestBuilder */ = { + 61054E402A6EE10A00AAA894 /* RequestBuilders */ = { isa = PBXGroup; children = ( - 61054E412A6EE10A00AAA894 /* RequestBuilder.swift */, + A74A72802B0CEE4900771FEB /* ResourceRequestBuilder.swift */, + 61054E412A6EE10A00AAA894 /* SegmentRequestBuilder.swift */, 61054E422A6EE10A00AAA894 /* JSON */, 61054E462A6EE10A00AAA894 /* Multipart */, ); - path = RequestBuilder; + path = RequestBuilders; sourceTree = ""; }; 61054E422A6EE10A00AAA894 /* JSON */ = { @@ -3382,7 +3402,8 @@ 61054F492A6EE1BA00AAA894 /* Writer */ = { isa = PBXGroup; children = ( - 61054F4A2A6EE1BA00AAA894 /* WriterTests.swift */, + A71013D52B178FAD00101E60 /* ResourcesWriterTests.swift */, + 61054F4A2A6EE1BA00AAA894 /* RecordsWriterTests.swift */, 61054F4B2A6EE1BA00AAA894 /* SRCompressionTests.swift */, 61054F4C2A6EE1BA00AAA894 /* Models */, ); @@ -3538,6 +3559,9 @@ 61054F852A6EE1BA00AAA894 /* MockImageDataProvider.swift */, 61054F862A6EE1BA00AAA894 /* SnapshotProducerMocks.swift */, 61054F872A6EE1BA00AAA894 /* RUMContextObserverMock.swift */, + A74A72862B10CE4100771FEB /* ResourceMocks.swift */, + A74A72882B10D95D00771FEB /* MultipartBuilderSpy.swift */, + A71265852B17980C007D63CE /* MockFeature.swift */, ); path = Mocks; sourceTree = ""; @@ -3557,7 +3581,8 @@ children = ( 61054F8C2A6EE1BA00AAA894 /* JSON */, 61054F8F2A6EE1BA00AAA894 /* Multipart */, - 61054F912A6EE1BA00AAA894 /* RequestBuilderTests.swift */, + 61054F912A6EE1BA00AAA894 /* SegmentRequestBuilderTests.swift */, + A74A72842B10CC6700771FEB /* ResourceRequestBuilderTests.swift */, ); path = RequestBuilder; sourceTree = ""; @@ -3590,6 +3615,7 @@ 610ABD492A69309900AFEA34 /* IntegrationUnitTests */ = { isa = PBXGroup; children = ( + 61E8C5062B28896100E709B4 /* RUM */, 610ABD4A2A6930AB00AFEA34 /* Public */, 618353BA2A6946F40085F84A /* Internal */, ); @@ -4216,7 +4242,7 @@ 6141CE652806B3F200EBB879 /* Utils */ = { isa = PBXGroup; children = ( - 61C1510C25AC8C1B00362D4B /* RUMViewIdentityTests.swift */, + 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */, 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */, ); path = Utils; @@ -4298,7 +4324,7 @@ 61494B7827F3522C0082BBCC /* Utils */ = { isa = PBXGroup; children = ( - 61FF9A4425AC5DEA001058CC /* RUMViewIdentity.swift */, + 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */, 61A614E7276B2BD000A06CE7 /* RUMOffViewEventsHandlingRule.swift */, ); path = Utils; @@ -4871,6 +4897,14 @@ path = RUMEvent; sourceTree = ""; }; + 61E8C5062B28896100E709B4 /* RUM */ = { + isa = PBXGroup; + children = ( + 61E8C5072B28898800E709B4 /* StartingRUMSessionTests.swift */, + ); + path = RUM; + sourceTree = ""; + }; 61E909E524A24DD3005EA2DE /* OpenTracing */ = { isa = PBXGroup; children = ( @@ -5148,14 +5182,14 @@ children = ( D295A16429F299C9007C0E9A /* URLSessionInterceptor.swift */, D2160CC129C0DED100FAA9A5 /* URLSessionTaskInterception.swift */, - D2160CC329C0DED100FAA9A5 /* DatadogURLSessionDelegate.swift */, - 3CBDE66D2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift */, - 3CBDE6702AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift */, 3CBDE6732AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift */, - 3CBDE6862AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift */, + D2160CC329C0DED100FAA9A5 /* DatadogURLSessionDelegate.swift */, 3CBDE6892AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift */, - 3C394EF62AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift */, - 3CB32AD32ACB733000D602ED /* URLSessionSwizzler.swift */, + D2181A8A2B0500BB00A518C0 /* NetworkInstrumentationSwizzler.swift */, + D2BEEDB42B33607D0065F3AC /* URLSessionSwizzler.swift */, + D2BEEDAB2B3356710065F3AC /* URLSessionTaskSwizzler.swift */, + D2BEEDB12B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift */, + D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */, ); path = URLSession; sourceTree = ""; @@ -5658,7 +5692,6 @@ D23039DC298D5235001A1FA3 /* DDError.swift */, 61133BBA2423979B00786299 /* SwiftExtensions.swift */, D29A9F9429DDB1DB005C54A4 /* UIKitExtensions.swift */, - 3C2206F22AB9CE9300DE780C /* MetaTypeExtensions.swift */, ); path = Utils; sourceTree = ""; @@ -5668,7 +5701,6 @@ children = ( 613C6B912768FF3100870CBF /* SamplerTests.swift */, 9E36D92124373EA700BFBDB7 /* SwiftExtensionsTests.swift */, - 3CFD81942ABBB66400977C22 /* MetaTypeExtensionsTests.swift */, ); path = Utils; sourceTree = ""; @@ -5780,10 +5812,10 @@ A79B0F60292BB071008742B3 /* B3HTTPHeadersReaderTests.swift */, A728ADA22934DB5000397996 /* W3CHTTPHeadersWriterTests.swift */, A728ADA52934DF2400397996 /* W3CHTTPHeadersReaderTests.swift */, - 3CBDE6802AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift */, - 3CBDE6832AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift */, - 3C394EF92AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift */, - 3CB32AD62ACB735600D602ED /* URLSessionSwizzlerTests.swift */, + D2181A8D2B051B7900A518C0 /* URLSessionSwizzlerTests.swift */, + D2BEEDAE2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift */, + D2BEEDB72B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift */, + D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */, ); path = NetworkInstrumentation; sourceTree = ""; @@ -7472,6 +7504,7 @@ D224430D29E95D6700274EC7 /* CrashReportReceiverTests.swift in Sources */, D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */, 61D3E0E4277B3D92008BE766 /* KronosNTPPacketTests.swift in Sources */, + 61E8C5082B28898800E709B4 /* StartingRUMSessionTests.swift in Sources */, 616B668E259CC28E00968EE8 /* DDRUMMonitorTests.swift in Sources */, 9EE5AD8226205B82001E699E /* DDNSURLSessionDelegateTests.swift in Sources */, 61133C4A2423990D00786299 /* DDConfigurationTests.swift in Sources */, @@ -7602,23 +7635,26 @@ buildActionMask = 2147483647; files = ( 61054EA22A6EE10B00AAA894 /* Scheduler.swift in Sources */, - 61054E5F2A6EE10A00AAA894 /* SRDataModels.swift in Sources */, + A7EA88562B17639A00FE2580 /* ResourcesWriter.swift in Sources */, 61054E8D2A6EE10A00AAA894 /* RUMContextReceiver.swift in Sources */, 61054E922A6EE10A00AAA894 /* SegmentJSONBuilder.swift in Sources */, - 61054E622A6EE10A00AAA894 /* Writer.swift in Sources */, + 61054E622A6EE10A00AAA894 /* RecordWriter.swift in Sources */, 61054E692A6EE10A00AAA894 /* ImageDataProvider.swift in Sources */, 61054E782A6EE10A00AAA894 /* UIDatePickerRecorder.swift in Sources */, 61054E822A6EE10A00AAA894 /* UILabelRecorder.swift in Sources */, + A73A54982B16406900E1F7E3 /* ResourcesFeature.swift in Sources */, 61054E6C2A6EE10A00AAA894 /* SystemColors.swift in Sources */, + A71265902B179C94007D63CE /* SRDataModels+UIKit.swift in Sources */, 61054E812A6EE10A00AAA894 /* UIStepperRecorder.swift in Sources */, 61054E632A6EE10A00AAA894 /* SessionReplayConfiguration.swift in Sources */, 61054E702A6EE10A00AAA894 /* TouchSnapshotProducer.swift in Sources */, 61054E652A6EE10A00AAA894 /* AppWindowObserver.swift in Sources */, + A74A72812B0CEE4900771FEB /* ResourceRequestBuilder.swift in Sources */, 61054E7B2A6EE10A00AAA894 /* UIViewRecorder.swift in Sources */, 61054E762A6EE10A00AAA894 /* ViewTreeSnapshotBuilder.swift in Sources */, 61054E802A6EE10A00AAA894 /* UIPickerViewRecorder.swift in Sources */, 61054E612A6EE10A00AAA894 /* SRCompression.swift in Sources */, - 61054E8F2A6EE10A00AAA894 /* RequestBuilder.swift in Sources */, + 61054E8F2A6EE10A00AAA894 /* SegmentRequestBuilder.swift in Sources */, 61054E8B2A6EE10A00AAA894 /* SessionReplayFeature.swift in Sources */, 61054E992A6EE10A00AAA894 /* WireframesBuilder.swift in Sources */, 61054E892A6EE10A00AAA894 /* NodeIDGenerator.swift in Sources */, @@ -7626,15 +7662,16 @@ D22C5BD02A98A6660024CC1F /* Baggages.swift in Sources */, 61054E902A6EE10A00AAA894 /* SegmentJSON.swift in Sources */, 61054E672A6EE10A00AAA894 /* Recorder.swift in Sources */, - 61054E5E2A6EE10A00AAA894 /* EnrichedRecord.swift in Sources */, 61054E6B2A6EE10A00AAA894 /* CFType+Safety.swift in Sources */, 61054E852A6EE10A00AAA894 /* UISegmentRecorder.swift in Sources */, 61054E982A6EE10A00AAA894 /* RecordsBuilder.swift in Sources */, 61054E9C2A6EE10B00AAA894 /* UIImage+Scaling.swift in Sources */, 61054EA12A6EE10B00AAA894 /* MainThreadScheduler.swift in Sources */, 61054E7C2A6EE10A00AAA894 /* UINavigationBarRecorder.swift in Sources */, + A71265922B179C94007D63CE /* EnrichedRecord.swift in Sources */, 61054E772A6EE10A00AAA894 /* ViewTreeRecorder.swift in Sources */, 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */, + A712658F2B179C94007D63CE /* EnrichedResource.swift in Sources */, 61054E872A6EE10A00AAA894 /* ViewAttributes+Copy.swift in Sources */, 61054E6A2A6EE10A00AAA894 /* UIKitExtensions.swift in Sources */, 61054E7D2A6EE10A00AAA894 /* UITextFieldRecorder.swift in Sources */, @@ -7659,9 +7696,9 @@ 61054E712A6EE10A00AAA894 /* TouchSnapshot.swift in Sources */, 61054E8A2A6EE10A00AAA894 /* WindowViewTreeSnapshotProducer.swift in Sources */, 61054E7A2A6EE10A00AAA894 /* UIImageViewRecorder.swift in Sources */, + A71265912B179C94007D63CE /* SRDataModels.swift in Sources */, 61054E752A6EE10A00AAA894 /* ViewTreeSnapshot.swift in Sources */, 61054EA02A6EE10B00AAA894 /* Colors.swift in Sources */, - 61054E602A6EE10A00AAA894 /* SRDataModels+UIKit.swift in Sources */, 61054E7F2A6EE10A00AAA894 /* UISliderRecorder.swift in Sources */, 61054E842A6EE10A00AAA894 /* UITabBarRecorder.swift in Sources */, 61054E9D2A6EE10B00AAA894 /* Cache.swift in Sources */, @@ -7677,7 +7714,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 61054FD42A6EE1BA00AAA894 /* RequestBuilderTests.swift in Sources */, + 61054FD42A6EE1BA00AAA894 /* SegmentRequestBuilderTests.swift in Sources */, 61054FB52A6EE1BA00AAA894 /* UISliderRecorderTests.swift in Sources */, 61054FB22A6EE1BA00AAA894 /* UILabelRecorderTests.swift in Sources */, 61054FCE2A6EE1BA00AAA894 /* RUMContextObserverMock.swift in Sources */, @@ -7697,6 +7734,7 @@ 61054F982A6EE1BA00AAA894 /* CGRectExtensionsTests.swift in Sources */, 61054FB42A6EE1BA00AAA894 /* UITabBarRecorderTests.swift in Sources */, 61054FA22A6EE1BA00AAA894 /* TextObfuscatorTests.swift in Sources */, + A71013D62B178FAD00101E60 /* ResourcesWriterTests.swift in Sources */, 61054FBE2A6EE1BA00AAA894 /* UIImageViewWireframesBuilderTests.swift in Sources */, 61054FBC2A6EE1BA00AAA894 /* UIStepperRecorderTests.swift in Sources */, 61054FCB2A6EE1BA00AAA894 /* QueueMocks.swift in Sources */, @@ -7710,10 +7748,13 @@ 61054FAC2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift in Sources */, 61054FC72A6EE1BA00AAA894 /* SRDataModelsMocks.swift in Sources */, 61054FC82A6EE1BA00AAA894 /* ProcessorSpy.swift in Sources */, + A74A72872B10CE4100771FEB /* ResourceMocks.swift in Sources */, 61054FA42A6EE1BA00AAA894 /* DiffTests.swift in Sources */, 61054FA02A6EE1BA00AAA894 /* SRCompressionTests.swift in Sources */, + A74A72852B10CC6700771FEB /* ResourceRequestBuilderTests.swift in Sources */, + A71265862B17980C007D63CE /* MockFeature.swift in Sources */, 61054FB62A6EE1BA00AAA894 /* UnsupportedViewRecorderTests.swift in Sources */, - 61054F9F2A6EE1BA00AAA894 /* WriterTests.swift in Sources */, + 61054F9F2A6EE1BA00AAA894 /* RecordsWriterTests.swift in Sources */, 61054FB82A6EE1BA00AAA894 /* UIDatePickerRecorderTests.swift in Sources */, 61054FD12A6EE1BA00AAA894 /* SegmentJSONBuilderTests.swift in Sources */, 61054FA32A6EE1BA00AAA894 /* Diff+SRWireframesTests.swift in Sources */, @@ -7722,6 +7763,7 @@ 61054FB92A6EE1BA00AAA894 /* UINavigationBarRecorderTests.swift in Sources */, 61054FA62A6EE1BA00AAA894 /* ProcessorTests.swift in Sources */, 61054FB72A6EE1BA00AAA894 /* UISegmentRecorderTests.swift in Sources */, + A74A72892B10D95D00771FEB /* MultipartBuilderSpy.swift in Sources */, 61054FCF2A6EE1BA00AAA894 /* RUMContextReceiverTests.swift in Sources */, 61054FC92A6EE1BA00AAA894 /* RecorderMocks.swift in Sources */, 61054FBB2A6EE1BA00AAA894 /* UISwitchRecorderTests.swift in Sources */, @@ -7936,12 +7978,11 @@ D2EBEE1F29BA160F00B15732 /* HTTPHeadersReader.swift in Sources */, D263BCAF29DAFFEB00FA0E21 /* PerformancePresetOverride.swift in Sources */, D23039E7298D5236001A1FA3 /* NetworkConnectionInfo.swift in Sources */, - 3CBDE6712AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift in Sources */, D23039E9298D5236001A1FA3 /* TrackingConsent.swift in Sources */, D2EBEE2629BA160F00B15732 /* B3HTTPHeaders.swift in Sources */, D23354FC2A42E32000AFCAE2 /* InternalExtended.swift in Sources */, - 3C2206F32AB9CE9300DE780C /* MetaTypeExtensions.swift in Sources */, D23039F3298D5236001A1FA3 /* DynamicCodingKey.swift in Sources */, + D2BEEDB22B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift in Sources */, D23039FE298D5236001A1FA3 /* FeatureRequestBuilder.swift in Sources */, D2160CE429C0DFEE00FAA9A5 /* MethodSwizzler.swift in Sources */, D2160CC929C0DED100FAA9A5 /* DatadogURLSessionDelegate.swift in Sources */, @@ -7949,13 +7990,16 @@ D2432CF929EDB22C00D93657 /* Flushable.swift in Sources */, D23039F7298D5236001A1FA3 /* AttributesSanitizer.swift in Sources */, D23039EB298D5236001A1FA3 /* DatadogFeature.swift in Sources */, + D2BEEDBA2B33638F0065F3AC /* NetworkInstrumentationSwizzler.swift in Sources */, 3CBDE6742AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift in Sources */, D23039E4298D5236001A1FA3 /* CarrierInfo.swift in Sources */, D2303A03298D5236001A1FA3 /* DDError.swift in Sources */, D23039F4298D5236001A1FA3 /* AnyCodable.swift in Sources */, D29A9F9529DDB1DB005C54A4 /* UIKitExtensions.swift in Sources */, + D2BEEDB52B3360820065F3AC /* URLSessionSwizzler.swift in Sources */, D2EBEE2529BA160F00B15732 /* TraceID.swift in Sources */, D2EBEE2129BA160F00B15732 /* W3CHTTPHeaders.swift in Sources */, + D2BEEDAC2B3356710065F3AC /* URLSessionTaskSwizzler.swift in Sources */, D23039E3298D5236001A1FA3 /* BatteryStatus.swift in Sources */, D2EBEE2A29BA160F00B15732 /* TracingHTTPHeaders.swift in Sources */, D23039EC298D5236001A1FA3 /* LaunchTime.swift in Sources */, @@ -7967,7 +8011,6 @@ D2160C9E29C0DE5700FAA9A5 /* TracingHeaderType.swift in Sources */, D23039F5298D5236001A1FA3 /* AnyEncodable.swift in Sources */, D2303A00298D5236001A1FA3 /* DatadogExtended.swift in Sources */, - 3CBDE66E2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift in Sources */, D23039E6298D5236001A1FA3 /* Sysctl.swift in Sources */, D2160CF429C0EDFC00FAA9A5 /* UploadPerformancePreset.swift in Sources */, D23039E1298D5236001A1FA3 /* AppState.swift in Sources */, @@ -7977,13 +8020,11 @@ D2EBEE2329BA160F00B15732 /* B3HTTPHeadersReader.swift in Sources */, D23039F8298D5236001A1FA3 /* InternalLogger.swift in Sources */, D2303A01298D5236001A1FA3 /* DateFormatting.swift in Sources */, - 3CBDE6872AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift in Sources */, D2216EC02A94DE2900ADAEC8 /* FeatureBaggage.swift in Sources */, D23039F1298D5236001A1FA3 /* AnyDecodable.swift in Sources */, D2160CC529C0DED100FAA9A5 /* URLSessionTaskInterception.swift in Sources */, D23039DD298D5235001A1FA3 /* DD.swift in Sources */, D2160C9A29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, - 3CB32AD42ACB733000D602ED /* URLSessionSwizzler.swift in Sources */, D2EBEE2229BA160F00B15732 /* TracePropagationHeadersReader.swift in Sources */, D2303A02298D5236001A1FA3 /* ReadWriteLock.swift in Sources */, D2EBEE2429BA160F00B15732 /* W3CHTTPHeadersReader.swift in Sources */, @@ -8000,7 +8041,6 @@ D23039F2298D5236001A1FA3 /* AnyDecoder.swift in Sources */, D23039EF298D5236001A1FA3 /* FeatureMessage.swift in Sources */, D2160CA029C0DE5700FAA9A5 /* HostsSanitizer.swift in Sources */, - 3C394EF72AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift in Sources */, D22F06D729DAFD500026CC3C /* FixedWidthInteger+Convenience.swift in Sources */, D295A16529F299C9007C0E9A /* URLSessionInterceptor.swift in Sources */, D23039E5298D5236001A1FA3 /* DateProvider.swift in Sources */, @@ -8010,6 +8050,7 @@ D2A783D429A5309F003B03BB /* SwiftExtensions.swift in Sources */, 3C0D5DD72A543B3B00446CF9 /* Event.swift in Sources */, 3CBDE68A2AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift in Sources */, + D270CDDD2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift in Sources */, D22F06D929DAFD500026CC3C /* TimeInterval+Convenience.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -8043,7 +8084,7 @@ D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */, D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */, D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */, - D23F8E6D29DDCD28001CFAE8 /* RUMViewIdentity.swift in Sources */, + D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */, 49D8C0B82AC5D2160075E427 /* RUM+Internal.swift in Sources */, D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */, 61C713BA2A3C935C00FA735A /* RUM.swift in Sources */, @@ -8118,7 +8159,7 @@ D23F8EB829DDCD38001CFAE8 /* UIKitRUMUserActionsHandlerTests.swift in Sources */, D23F8EB929DDCD38001CFAE8 /* RUMFeatureMocks.swift in Sources */, 61C713AE2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, - D23F8EBA29DDCD38001CFAE8 /* RUMViewIdentityTests.swift in Sources */, + D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */, D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */, D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */, D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */, @@ -8302,7 +8343,7 @@ D29A9F6829DD85BB005C54A4 /* RUMContextAttributes.swift in Sources */, D29A9F6329DD85BB005C54A4 /* RUMMonitor.swift in Sources */, D29A9F7029DD85BB005C54A4 /* RUMContextProvider.swift in Sources */, - D29A9F6029DD85BB005C54A4 /* RUMViewIdentity.swift in Sources */, + D29A9F6029DD85BB005C54A4 /* ViewIdentifier.swift in Sources */, 49D8C0B72AC5D2160075E427 /* RUM+Internal.swift in Sources */, D29A9F7629DD85BB005C54A4 /* RUMViewsHandler.swift in Sources */, 61C713B92A3C935C00FA735A /* RUM.swift in Sources */, @@ -8377,7 +8418,7 @@ D29A9FAC29DDB483005C54A4 /* UIKitRUMUserActionsHandlerTests.swift in Sources */, D29A9FC029DDB540005C54A4 /* RUMFeatureMocks.swift in Sources */, 61C713AD2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, - D29A9FB729DDB483005C54A4 /* RUMViewIdentityTests.swift in Sources */, + D29A9FB729DDB483005C54A4 /* ViewIdentifierTests.swift in Sources */, D29A9FA429DDB483005C54A4 /* WebViewEventReceiverTests.swift in Sources */, D29A9F9A29DDB483005C54A4 /* URLSessionRUMResourcesHandlerTests.swift in Sources */, D29A9FA229DDB483005C54A4 /* RUMEventSanitizerTests.swift in Sources */, @@ -8641,6 +8682,7 @@ D2CB6F6427C520D400A62B57 /* LoggerTests.swift in Sources */, D29A9FD929DDC687005C54A4 /* UIKitRUMViewsPredicateTests.swift in Sources */, D2CB6F6627C520D400A62B57 /* RUMMonitorTests.swift in Sources */, + 61E8C5092B28898800E709B4 /* StartingRUMSessionTests.swift in Sources */, D22743DF29DEB8B5001A7EF9 /* VitalMemoryReaderTests.swift in Sources */, D2CB6F6827C520D400A62B57 /* SwiftUIExtensionsTests.swift in Sources */, D2CB6F6A27C520D400A62B57 /* DDRUMMonitor+apiTests.m in Sources */, @@ -8741,12 +8783,11 @@ D2EBEE2D29BA161100B15732 /* HTTPHeadersReader.swift in Sources */, D263BCB029DAFFEB00FA0E21 /* PerformancePresetOverride.swift in Sources */, D2DA2359298D57AA00C6C7E6 /* NetworkConnectionInfo.swift in Sources */, - 3CBDE6722AA08C0B00F6A7B6 /* URLSessionTaskSwizzler.swift in Sources */, D2DA235A298D57AA00C6C7E6 /* TrackingConsent.swift in Sources */, D2EBEE3429BA161100B15732 /* B3HTTPHeaders.swift in Sources */, D23354FD2A42E32000AFCAE2 /* InternalExtended.swift in Sources */, - 3C2206F42AB9CE9300DE780C /* MetaTypeExtensions.swift in Sources */, D2DA235B298D57AA00C6C7E6 /* DynamicCodingKey.swift in Sources */, + D2BEEDB32B335DA90065F3AC /* URLSessionTaskDelegateSwizzler.swift in Sources */, D2DA235C298D57AA00C6C7E6 /* FeatureRequestBuilder.swift in Sources */, D2160CE629C0DFEE00FAA9A5 /* MethodSwizzler.swift in Sources */, D2160CCA29C0DED100FAA9A5 /* DatadogURLSessionDelegate.swift in Sources */, @@ -8754,13 +8795,16 @@ D2432CFA29EDB22C00D93657 /* Flushable.swift in Sources */, D2DA235D298D57AA00C6C7E6 /* AttributesSanitizer.swift in Sources */, D2DA235E298D57AA00C6C7E6 /* DatadogFeature.swift in Sources */, + D2BEEDBB2B3363900065F3AC /* NetworkInstrumentationSwizzler.swift in Sources */, 3CBDE6752AA08C2F00F6A7B6 /* URLSessionInstrumentation.swift in Sources */, D2DA235F298D57AA00C6C7E6 /* CarrierInfo.swift in Sources */, D2DA2360298D57AA00C6C7E6 /* DDError.swift in Sources */, D2DA2361298D57AA00C6C7E6 /* AnyCodable.swift in Sources */, D29A9F9629DDB1DB005C54A4 /* UIKitExtensions.swift in Sources */, + D2BEEDB62B3360830065F3AC /* URLSessionSwizzler.swift in Sources */, D2EBEE3329BA161100B15732 /* TraceID.swift in Sources */, D2EBEE2F29BA161100B15732 /* W3CHTTPHeaders.swift in Sources */, + D2BEEDAD2B3356710065F3AC /* URLSessionTaskSwizzler.swift in Sources */, D2DA2363298D57AA00C6C7E6 /* BatteryStatus.swift in Sources */, D2EBEE3829BA161100B15732 /* TracingHTTPHeaders.swift in Sources */, D2DA2364298D57AA00C6C7E6 /* LaunchTime.swift in Sources */, @@ -8772,7 +8816,6 @@ D2160C9F29C0DE5700FAA9A5 /* TracingHeaderType.swift in Sources */, D2DA2369298D57AA00C6C7E6 /* AnyEncodable.swift in Sources */, D2DA236A298D57AA00C6C7E6 /* DatadogExtended.swift in Sources */, - 3CBDE66F2AA08BF600F6A7B6 /* URLSessionTaskDelegateSwizzler.swift in Sources */, D2DA236B298D57AA00C6C7E6 /* Sysctl.swift in Sources */, D2160CF529C0EDFC00FAA9A5 /* UploadPerformancePreset.swift in Sources */, D2DA236C298D57AA00C6C7E6 /* AppState.swift in Sources */, @@ -8782,13 +8825,11 @@ D2EBEE3129BA161100B15732 /* B3HTTPHeadersReader.swift in Sources */, D2DA236E298D57AA00C6C7E6 /* InternalLogger.swift in Sources */, D2DA236F298D57AA00C6C7E6 /* DateFormatting.swift in Sources */, - 3CBDE6882AA0B7F000F6A7B6 /* URLSessionTaskDelegate+Tracking.swift in Sources */, D2216EC12A94DE2900ADAEC8 /* FeatureBaggage.swift in Sources */, D2DA2370298D57AA00C6C7E6 /* AnyDecodable.swift in Sources */, D2160CC629C0DED100FAA9A5 /* URLSessionTaskInterception.swift in Sources */, D2DA2372298D57AA00C6C7E6 /* DD.swift in Sources */, D2160C9B29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, - 3CB32AD52ACB733000D602ED /* URLSessionSwizzler.swift in Sources */, D2EBEE3029BA161100B15732 /* TracePropagationHeadersReader.swift in Sources */, D2DA2373298D57AA00C6C7E6 /* ReadWriteLock.swift in Sources */, D2EBEE3229BA161100B15732 /* W3CHTTPHeadersReader.swift in Sources */, @@ -8805,7 +8846,6 @@ D2DA2379298D57AA00C6C7E6 /* AnyDecoder.swift in Sources */, D2DA237A298D57AA00C6C7E6 /* FeatureMessage.swift in Sources */, D2160CA129C0DE5700FAA9A5 /* HostsSanitizer.swift in Sources */, - 3C394EF82AA5F49F008F48BA /* URLSessionDataDelegateSwizzler.swift in Sources */, D22F06D829DAFD500026CC3C /* FixedWidthInteger+Convenience.swift in Sources */, D295A16629F299C9007C0E9A /* URLSessionInterceptor.swift in Sources */, D2DA237B298D57AA00C6C7E6 /* DateProvider.swift in Sources */, @@ -8815,6 +8855,7 @@ D2A783D529A530A0003B03BB /* SwiftExtensions.swift in Sources */, 3C0D5DD82A543B3B00446CF9 /* Event.swift in Sources */, 3CBDE68B2AA0C47300F6A7B6 /* URLSessionTask+Tracking.swift in Sources */, + D270CDDE2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift in Sources */, D22F06DA29DAFD500026CC3C /* TimeInterval+Convenience.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -8823,24 +8864,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3C394EFA2AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift in Sources */, + D2BEEDAF2B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift in Sources */, D26416B62A30E84F00BCD9F7 /* CoreRegistryTest.swift in Sources */, D2EBEE3C29BA163E00B15732 /* B3HTTPHeadersWriterTests.swift in Sources */, - 3CBDE6812AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */, D21AE6BC29E5EDAF0064BF29 /* TelemetryTests.swift in Sources */, D2DA23A3298D58F400C6C7E6 /* AnyEncodableTests.swift in Sources */, D263BCB429DB014900FA0E21 /* FixedWidthInteger+ConvenienceTests.swift in Sources */, 3C0D5DF52A5443B100446CF9 /* DataFormatTests.swift in Sources */, D2EBEE4429BA168200B15732 /* TraceIDTests.swift in Sources */, - 3CBDE6842AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift in Sources */, D2EBEE4329BA168200B15732 /* TraceIDGeneratorTests.swift in Sources */, D2DA23A7298D58F400C6C7E6 /* AppStateHistoryTests.swift in Sources */, D2DA23A5298D58F400C6C7E6 /* AnyDecodableTests.swift in Sources */, - 3CFD81952ABBB66400977C22 /* MetaTypeExtensionsTests.swift in Sources */, D2EBEE3D29BA163E00B15732 /* W3CHTTPHeadersWriterTests.swift in Sources */, - 3CB32AD72ACB735600D602ED /* URLSessionSwizzlerTests.swift in Sources */, D2DA23A4298D58F400C6C7E6 /* AnyCodableTests.swift in Sources */, D2160CDE29C0DF6700FAA9A5 /* URLSessionTaskInterceptionTests.swift in Sources */, + D270CDE02B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */, D2DA23A1298D58F400C6C7E6 /* ReadWriteLockTests.swift in Sources */, D2160CD829C0DF6700FAA9A5 /* FirstPartyHostsTests.swift in Sources */, D20731CD29A52E8700ECBF94 /* SamplerTests.swift in Sources */, @@ -8853,6 +8891,8 @@ D2F44FB8299AA1DA0074B0D9 /* DataCompressionTests.swift in Sources */, D2160CE029C0DF6700FAA9A5 /* URLSessionDelegateAsSuperclassTests.swift in Sources */, D2EBEE3B29BA163E00B15732 /* B3HTTPHeadersReaderTests.swift in Sources */, + D2BEEDB82B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */, + D2181A8E2B051B7900A518C0 /* URLSessionSwizzlerTests.swift in Sources */, D2A783DA29A530EF003B03BB /* SwiftExtensionsTests.swift in Sources */, D2D36DCB2AC6DCCA0021F28A /* DatadogCoreProtocolTests.swift in Sources */, D2160CD429C0DF6700FAA9A5 /* NetworkInstrumentationFeatureTests.swift in Sources */, @@ -8866,24 +8906,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3C394EFB2AA5F4C8008F48BA /* URLSessionDataDelegateSwizzlerTests.swift in Sources */, + D2BEEDB02B335C400065F3AC /* URLSessionTaskSwizzlerTests.swift in Sources */, D26416B72A30E84F00BCD9F7 /* CoreRegistryTest.swift in Sources */, D2EBEE4029BA163F00B15732 /* B3HTTPHeadersWriterTests.swift in Sources */, - 3CBDE6822AA092A200F6A7B6 /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */, D21AE6BD29E5EDAF0064BF29 /* TelemetryTests.swift in Sources */, D2DA23B1298D59DC00C6C7E6 /* AnyEncodableTests.swift in Sources */, D263BCB529DB014900FA0E21 /* FixedWidthInteger+ConvenienceTests.swift in Sources */, 3C0D5DF62A5443B100446CF9 /* DataFormatTests.swift in Sources */, D2EBEE4629BA168400B15732 /* TraceIDTests.swift in Sources */, - 3CBDE6852AA092BC00F6A7B6 /* URLSessionTaskSwizzlerTests.swift in Sources */, D2EBEE4529BA168400B15732 /* TraceIDGeneratorTests.swift in Sources */, D2DA23B2298D59DC00C6C7E6 /* AppStateHistoryTests.swift in Sources */, D2DA23B3298D59DC00C6C7E6 /* AnyDecodableTests.swift in Sources */, - 3CFD81962ABBB66400977C22 /* MetaTypeExtensionsTests.swift in Sources */, D2EBEE4129BA163F00B15732 /* W3CHTTPHeadersWriterTests.swift in Sources */, - 3CB32AD82ACB735600D602ED /* URLSessionSwizzlerTests.swift in Sources */, D2DA23B4298D59DC00C6C7E6 /* AnyCodableTests.swift in Sources */, D2160CDF29C0DF6700FAA9A5 /* URLSessionTaskInterceptionTests.swift in Sources */, + D270CDE12B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */, D2DA23B5298D59DC00C6C7E6 /* ReadWriteLockTests.swift in Sources */, D2160CD929C0DF6700FAA9A5 /* FirstPartyHostsTests.swift in Sources */, D20731CE29A52E8700ECBF94 /* SamplerTests.swift in Sources */, @@ -8896,6 +8933,8 @@ D2F44FB9299AA1DB0074B0D9 /* DataCompressionTests.swift in Sources */, D2160CE129C0DF6700FAA9A5 /* URLSessionDelegateAsSuperclassTests.swift in Sources */, D2EBEE3F29BA163F00B15732 /* B3HTTPHeadersReaderTests.swift in Sources */, + D2BEEDB92B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift in Sources */, + D2181A8F2B051B7900A518C0 /* URLSessionSwizzlerTests.swift in Sources */, D2A783D929A530EF003B03BB /* SwiftExtensionsTests.swift in Sources */, D2D36DCC2AC6DCCA0021F28A /* DatadogCoreProtocolTests.swift in Sources */, D2160CD529C0DF6700FAA9A5 /* NetworkInstrumentationFeatureTests.swift in Sources */, diff --git a/Datadog/Datadog.xcodeproj/xcshareddata/xcschemes/Example iOS.xcscheme b/Datadog/Datadog.xcodeproj/xcshareddata/xcschemes/Example iOS.xcscheme index 32d0567998..bc62ca4664 100644 --- a/Datadog/Datadog.xcodeproj/xcshareddata/xcschemes/Example iOS.xcscheme +++ b/Datadog/Datadog.xcodeproj/xcshareddata/xcschemes/Example iOS.xcscheme @@ -32,8 +32,8 @@ WritableFile func getWritableFile(writeSize: UInt64) throws -> WritableFile - func getReadableFile(excludingFilesNamed excludedFileNames: Set) -> ReadableFile? + func getReadableFiles(excludingFilesNamed excludedFileNames: Set, limit: Int) -> [ReadableFile] func delete(readableFile: ReadableFile, deletionReason: BatchDeletedMetric.RemovalReason) var ignoreFilesAgeWhenReading: Bool { get set } @@ -150,33 +150,32 @@ internal class FilesOrchestrator: FilesOrchestratorType { // MARK: - `ReadableFile` orchestration - func getReadableFile(excludingFilesNamed excludedFileNames: Set = []) -> ReadableFile? { + func getReadableFiles(excludingFilesNamed excludedFileNames: Set = [], limit: Int = .max) -> [ReadableFile] { do { - let filesWithCreationDate = try directory.files() + let filesFromOldest = try directory.files() .map { (file: $0, creationDate: fileCreationDateFrom(fileName: $0.name)) } .compactMap { try deleteFileIfItsObsolete(file: $0.file, fileCreationDate: $0.creationDate) } - - guard let (oldestFile, creationDate) = filesWithCreationDate - .filter({ excludedFileNames.contains($0.file.name) == false }) .sorted(by: { $0.creationDate < $1.creationDate }) - .first - else { - return nil - } #if DD_SDK_COMPILED_FOR_TESTING if ignoreFilesAgeWhenReading { - return oldestFile + return filesFromOldest + .prefix(limit) + .map { $0.file } } #endif - let oldestFileAge = dateProvider.now.timeIntervalSince(creationDate) - let fileIsOldEnough = oldestFileAge >= performance.minFileAgeForRead - - return fileIsOldEnough ? oldestFile : nil + let filtered = filesFromOldest + .filter { + let fileAge = dateProvider.now.timeIntervalSince($0.creationDate) + return excludedFileNames.contains($0.file.name) == false && fileAge >= performance.minFileAgeForRead + } + return filtered + .prefix(limit) + .map { $0.file } } catch { telemetry.error("Failed to obtain readable file", error: error) - return nil + return [] } } diff --git a/DatadogCore/Sources/Core/Storage/Reading/DataReader.swift b/DatadogCore/Sources/Core/Storage/Reading/DataReader.swift index e007ee5726..454bcc7ada 100644 --- a/DatadogCore/Sources/Core/Storage/Reading/DataReader.swift +++ b/DatadogCore/Sources/Core/Storage/Reading/DataReader.swift @@ -17,9 +17,15 @@ internal final class DataReader: Reader { self.fileReader = fileReader } - func readNextBatch() -> Batch? { + func readFiles(limit: Int) -> [ReadableFile] { queue.sync { - self.fileReader.readNextBatch() + self.fileReader.readFiles(limit: limit) + } + } + + func readBatch(from file: ReadableFile) -> Batch? { + queue.sync { + self.fileReader.readBatch(from: file) } } diff --git a/DatadogCore/Sources/Core/Storage/Reading/FileReader.swift b/DatadogCore/Sources/Core/Storage/Reading/FileReader.swift index 1a9ac0dff4..8f05d6168f 100644 --- a/DatadogCore/Sources/Core/Storage/Reading/FileReader.swift +++ b/DatadogCore/Sources/Core/Storage/Reading/FileReader.swift @@ -30,11 +30,11 @@ internal final class FileReader: Reader { // MARK: - Reading batches - func readNextBatch() -> Batch? { - guard let file = orchestrator.getReadableFile(excludingFilesNamed: filesRead) else { - return nil - } + func readFiles(limit: Int) -> [ReadableFile] { + return orchestrator.getReadableFiles(excludingFilesNamed: filesRead, limit: limit) + } + func readBatch(from file: ReadableFile) -> Batch? { do { let dataBlocks = try decode(stream: file.stream()) return Batch(dataBlocks: dataBlocks, file: file) diff --git a/DatadogCore/Sources/Core/Storage/Reading/Reader.swift b/DatadogCore/Sources/Core/Storage/Reading/Reader.swift index 1c465440e3..00a6bc06d7 100644 --- a/DatadogCore/Sources/Core/Storage/Reading/Reader.swift +++ b/DatadogCore/Sources/Core/Storage/Reading/Reader.swift @@ -24,6 +24,14 @@ extension Batch { /// A type, reading batched data. internal protocol Reader { - func readNextBatch() -> Batch? + /// Reads files from the storage. + /// - Parameter limit: maximum number of files to read. + func readFiles(limit: Int) -> [ReadableFile] + /// Reads batch from given file. + /// - Parameter file: file to read batch from. + func readBatch(from file: ReadableFile) -> Batch? + /// Marks given batch as read. + /// - Parameter batch: batch to mark as read. + /// - Parameter reason: reason for removing the batch. func markBatchAsRead(_ batch: Batch, reason: BatchDeletedMetric.RemovalReason) } diff --git a/DatadogCore/Sources/Core/Upload/DataUploadStatus.swift b/DatadogCore/Sources/Core/Upload/DataUploadStatus.swift index a21f7542d6..42203a55e3 100644 --- a/DatadogCore/Sources/Core/Upload/DataUploadStatus.swift +++ b/DatadogCore/Sources/Core/Upload/DataUploadStatus.swift @@ -49,7 +49,7 @@ private enum HTTPResponseStatusCode: Int { /// The status of a single upload attempt. internal struct DataUploadStatus { /// If upload needs to be retried (`true`) because its associated data was not delivered but it may succeed - /// in the next attempt (i.e. it failed due to device leaving signal range or a temporary server unavailability occured). + /// in the next attempt (i.e. it failed due to device leaving signal range or a temporary server unavailability occurred). /// If set to `false` then data associated with the upload should be deleted as it does not need any more upload /// attempts (i.e. the upload succeeded or failed due to unrecoverable client error). let needsRetry: Bool diff --git a/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift b/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift index b771c168a1..809f848d67 100644 --- a/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift +++ b/DatadogCore/Sources/Core/Upload/DataUploadWorker.swift @@ -28,9 +28,16 @@ internal class DataUploadWorker: DataUploadWorkerType { private let contextProvider: DatadogContextProvider /// Delay used to schedule consecutive uploads. private let delay: DataUploadDelay - - /// Upload work scheduled by this worker. + /// Maximum number of batches to upload in one request. + private let maxBatchesPerUpload: Int + + /// Batch reading work scheduled by this worker. + @ReadWriteLock + private var readWork: DispatchWorkItem? + /// Batch upload work scheduled by this worker. + @ReadWriteLock private var uploadWork: DispatchWorkItem? + /// Telemetry interface. private let telemetry: Telemetry @@ -46,6 +53,7 @@ internal class DataUploadWorker: DataUploadWorkerType { delay: DataUploadDelay, featureName: String, telemetry: Telemetry, + maxBatchesPerUpload: Int, backgroundTaskCoordinator: BackgroundTaskCoordinator? = nil ) { self.queue = queue @@ -55,84 +63,109 @@ internal class DataUploadWorker: DataUploadWorkerType { self.contextProvider = contextProvider self.backgroundTaskCoordinator = backgroundTaskCoordinator self.delay = delay + self.maxBatchesPerUpload = maxBatchesPerUpload self.featureName = featureName self.telemetry = telemetry - let uploadWork = DispatchWorkItem { [weak self] in + self.readWork = DispatchWorkItem { [weak self] in guard let self = self else { return } - let context = contextProvider.read() - let blockersForUpload = self.uploadConditions.blockersForUpload(with: context) + let blockersForUpload = uploadConditions.blockersForUpload(with: context) let isSystemReady = blockersForUpload.isEmpty - let nextBatch = isSystemReady ? self.fileReader.readNextBatch() : nil - if let batch = nextBatch { + let files = isSystemReady ? fileReader.readFiles(limit: maxBatchesPerUpload) : nil + if let files = files, !files.isEmpty { + DD.logger.debug("⏳ (\(self.featureName)) Uploading batches...") self.backgroundTaskCoordinator?.beginBackgroundTask() - DD.logger.debug("⏳ (\(self.featureName)) Uploading batch...") + self.uploadFile(from: files.reversed(), context: context) + } else { + let batchLabel = files?.isEmpty == false ? "YES" : (isSystemReady ? "NO" : "NOT CHECKED") + DD.logger.debug("💡 (\(self.featureName)) No upload. Batch to upload: \(batchLabel), System conditions: \(blockersForUpload.description)") + self.delay.increase() + self.backgroundTaskCoordinator?.endBackgroundTask() + self.scheduleNextCycle() + } + } + scheduleNextCycle() + } + private func scheduleNextCycle() { + guard let readWork = self.readWork else { + return + } + queue.asyncAfter(deadline: .now() + delay.current, execute: readWork) + } + + private func uploadFile(from files: [ReadableFile], context: DatadogContext) { + let uploadWork = DispatchWorkItem { [weak self] in + guard let self = self else { + return + } + var files = files + guard let file = files.popLast() else { + self.scheduleNextCycle() + return + } + if let batch = self.fileReader.readBatch(from: file) { do { - // Upload batch let uploadStatus = try self.dataUploader.upload( events: batch.events, context: context ) - - // Delete or keep batch depending on the upload status if uploadStatus.needsRetry { - self.delay.increase() - DD.logger.debug(" → (\(self.featureName)) not delivered, will be retransmitted: \(uploadStatus.userDebugDescription)") + self.delay.increase() + self.scheduleNextCycle() + return } else { - self.fileReader.markBatchAsRead(batch, reason: .intakeCode(responseCode: uploadStatus.responseCode ?? -1)) // -1 is unexpected here - self.delay.decrease() - DD.logger.debug(" → (\(self.featureName)) accepted, won't be retransmitted: \(uploadStatus.userDebugDescription)") + if files.isEmpty { + self.delay.decrease() + } + self.fileReader.markBatchAsRead( + batch, + reason: .intakeCode(responseCode: uploadStatus.responseCode) + ) } - switch uploadStatus.error { - case .unauthorized: - DD.logger.error("⚠️ Make sure that the provided token still exists and you're targeting the relevant Datadog site.") - case let .httpError(statusCode: statusCode): - telemetry.error("Data upload finished with status code: \(statusCode)") - case let .networkError(error: error): - telemetry.error("Data upload finished with error", error: error) - case .none: break + if let error = uploadStatus.error { + switch error { + case .unauthorized: + DD.logger.error("⚠️ Make sure that the provided token still exists and you're targeting the relevant Datadog site.") + case let .httpError(statusCode: statusCode): + self.telemetry.error("Data upload finished with status code: \(statusCode)") + case let .networkError(error: error): + self.telemetry.error("Data upload finished with error", error: error) + } } } catch let error { // If upload can't be initiated do not retry, so drop the batch: self.fileReader.markBatchAsRead(batch, reason: .invalid) - telemetry.error("Failed to initiate '\(self.featureName)' data upload", error: error) + self.telemetry.error("Failed to initiate '\(self.featureName)' data upload", error: error) } + } + if files.isEmpty { + self.scheduleNextCycle() } else { - let batchLabel = nextBatch != nil ? "YES" : (isSystemReady ? "NO" : "NOT CHECKED") - DD.logger.debug("💡 (\(self.featureName)) No upload. Batch to upload: \(batchLabel), System conditions: \(blockersForUpload.description)") - - self.delay.increase() - self.backgroundTaskCoordinator?.endBackgroundTask() + self.uploadFile(from: files, context: context) } - - self.scheduleNextUpload(after: self.delay.current) } - self.uploadWork = uploadWork - - scheduleNextUpload(after: self.delay.current) - } - - private func scheduleNextUpload(after delay: TimeInterval) { - guard let work = uploadWork else { - return - } - - queue.asyncAfter(deadline: .now() + delay, execute: work) + queue.async(execute: uploadWork) } /// Sends all unsent data synchronously. /// - It performs arbitrary upload (without checking upload condition and without re-transmitting failed uploads). internal func flushSynchronously() { - queue.sync { - while let nextBatch = self.fileReader.readNextBatch() { + queue.sync { [weak self] in + guard let self = self else { + return + } + for file in self.fileReader.readFiles(limit: .max) { + guard let nextBatch = self.fileReader.readBatch(from: file) else { + continue + } defer { // RUMM-3459 Delete the underlying batch with `.flushed` reason that will be ignored in reported // metrics or telemetry. This is legitimate as long as `flush()` routine is only available for testing @@ -141,9 +174,9 @@ internal class DataUploadWorker: DataUploadWorkerType { } do { // Try uploading the batch and do one more retry on failure. - _ = try self.dataUploader.upload(events: nextBatch.events, context: contextProvider.read()) + _ = try self.dataUploader.upload(events: nextBatch.events, context: self.contextProvider.read()) } catch { - _ = try? self.dataUploader.upload(events: nextBatch.events, context: contextProvider.read()) + _ = try? self.dataUploader.upload(events: nextBatch.events, context: self.contextProvider.read()) } } } @@ -153,12 +186,17 @@ internal class DataUploadWorker: DataUploadWorkerType { /// - It does not affect the upload that has already begun. /// - It blocks the caller thread if called in the middle of upload execution. internal func cancelSynchronously() { - queue.sync { + queue.sync { [weak self] in + guard let self = self else { + return + } // This cancellation must be performed on the `queue` to ensure that it is not called // in the middle of a `DispatchWorkItem` execution - otherwise, as the pending block would be // fully executed, it will schedule another upload by calling `nextScheduledWork(after:)` at the end. self.uploadWork?.cancel() self.uploadWork = nil + self.readWork?.cancel() + self.readWork = nil } } } @@ -181,7 +219,9 @@ fileprivate extension Array where Element == DataUploadConditions.Blocker { if self.isEmpty { return "✅" } else { - return "❌ [upload was skipped because: " + self.map { $0.description }.joined(separator: " AND ") + "]" + return "❌ [upload was skipped because: " + map { + $0.description + }.joined(separator: " AND ") + "]" } } } diff --git a/DatadogCore/Sources/Core/Upload/FeatureUpload.swift b/DatadogCore/Sources/Core/Upload/FeatureUpload.swift index ef6b527ccb..1234ab9c2c 100644 --- a/DatadogCore/Sources/Core/Upload/FeatureUpload.swift +++ b/DatadogCore/Sources/Core/Upload/FeatureUpload.swift @@ -19,6 +19,7 @@ internal struct FeatureUpload { httpClient: HTTPClient, performance: PerformancePreset, backgroundTasksEnabled: Bool, + maxBatchesPerUpload: Int, telemetry: Telemetry ) { let uploadQueue = DispatchQueue( @@ -50,6 +51,7 @@ internal struct FeatureUpload { delay: DataUploadDelay(performance: performance), featureName: featureName, telemetry: telemetry, + maxBatchesPerUpload: maxBatchesPerUpload, backgroundTaskCoordinator: backgroundTaskCoordinator ) ) diff --git a/DatadogCore/Sources/Datadog.swift b/DatadogCore/Sources/Datadog.swift index d1546f8049..425f0203bd 100644 --- a/DatadogCore/Sources/Datadog.swift +++ b/DatadogCore/Sources/Datadog.swift @@ -31,7 +31,7 @@ import DatadogInternal /// ) /// ``` /// -public struct Datadog { +public enum Datadog { /// Configuration of Datadog SDK. public struct Configuration { /// Defines the Datadog SDK policy when batching data together before uploading it to Datadog servers. @@ -55,6 +55,24 @@ public struct Datadog { case rare } + /// Defines the maximum amount of batches processed sequentially without a delay within one reading/uploading cycle. + public enum BatchProcessingLevel { + case low + case medium + case high + + var maxBatchesPerUpload: Int { + switch self { + case .low: + return 1 + case .medium: + return 10 + case .high: + return 100 + } + } + } + /// Either the RUM client token (which supports RUM, Logging and APM) or regular client token, only for Logging and APM. public var clientToken: String @@ -105,12 +123,17 @@ public struct Datadog { /// The bundle object that contains the current executable. public var bundle: Bundle + /// Batch provessing level, defining the maximum number of batches processed sequencially without a delay within one reading/uploading cycle. + /// + /// `.medium` by default. + public var batchProcessingLevel: BatchProcessingLevel + /// Flag that determines if UIApplication methods [`beginBackgroundTask(expirationHandler:)`](https://developer.apple.com/documentation/uikit/uiapplication/1623031-beginbackgroundtaskwithexpiratio) and [`endBackgroundTask:`](https://developer.apple.com/documentation/uikit/uiapplication/1622970-endbackgroundtask) /// are utilized to perform background uploads. It may extend the amount of time the app is operating in background by 30 seconds. /// /// Tasks are normally stopped when there's nothing to upload or when encountering any upload blocker such us no internet connection or low battery. /// - /// By default it's set to `false`. + /// `false` by default. public var backgroundTasksEnabled: Bool /// Creates a Datadog SDK Configuration object. @@ -166,6 +189,7 @@ public struct Datadog { proxyConfiguration: [AnyHashable: Any]? = nil, encryption: DataEncryption? = nil, serverDateProvider: ServerDateProvider? = nil, + batchProcessingLevel: BatchProcessingLevel = .medium, backgroundTasksEnabled: Bool = false ) { self.clientToken = clientToken @@ -178,6 +202,7 @@ public struct Datadog { self.proxyConfiguration = proxyConfiguration self.encryption = encryption self.serverDateProvider = serverDateProvider ?? DatadogNTPDateProvider() + self.batchProcessingLevel = batchProcessingLevel self.backgroundTasksEnabled = backgroundTasksEnabled } @@ -372,6 +397,7 @@ public struct Datadog { let source = configuration.additionalConfiguration[CrossPlatformAttributes.ddsource] as? String ?? "ios" let variant = configuration.additionalConfiguration[CrossPlatformAttributes.variant] as? String let sdkVersion = configuration.additionalConfiguration[CrossPlatformAttributes.sdkVersion] as? String ?? __sdkVersion + let buildId = configuration.additionalConfiguration[CrossPlatformAttributes.buildId] as? String let performance = PerformancePreset( batchSize: debug ? .small : configuration.batchSize, @@ -398,6 +424,7 @@ public struct Datadog { env: try ifValid(env: configuration.env), version: applicationVersion, buildNumber: applicationBuildNumber, + buildId: buildId, variant: variant, source: source, sdkVersion: sdkVersion, @@ -411,10 +438,13 @@ public struct Datadog { serverDateProvider: configuration.serverDateProvider ), applicationVersion: applicationVersion, + maxBatchesPerUpload: configuration.batchProcessingLevel.maxBatchesPerUpload, backgroundTasksEnabled: configuration.backgroundTasksEnabled ) core.telemetry.configuration( + backgroundTasksEnabled: configuration.backgroundTasksEnabled, + batchProcessingLevel: Int64(exactly: configuration.batchProcessingLevel.maxBatchesPerUpload), batchSize: Int64(exactly: performance.maxFileSize), batchUploadFrequency: performance.minUploadDelay.toInt64Milliseconds, useLocalEncryption: configuration.encryption != nil, diff --git a/DatadogCore/Sources/Utils/CoreMetrics.swift b/DatadogCore/Sources/Utils/CoreMetrics.swift index 16e68eb7b1..963e142d9f 100644 --- a/DatadogCore/Sources/Utils/CoreMetrics.swift +++ b/DatadogCore/Sources/Utils/CoreMetrics.swift @@ -20,6 +20,7 @@ internal enum BatchMetric { case "logging": return "logs" case "tracing": return "trace" case "session-replay": return "sr" + case "session-replay-resources": return "sr-resources" default: return nil } } @@ -62,7 +63,7 @@ internal enum BatchDeletedMetric { /// The intake-code-202 represents a successful delivery. While some status codes, such as 401, indicate unrecoverable /// user errors, others, like 400, will indicate faults within the SDK. It is important to note that not all status codes will appear /// in this field, as the SDKs implement retry mechanisms for certain codes, e.g. 503 (see: ``DataUploadStatus``). - case intakeCode(responseCode: Int) + case intakeCode(responseCode: Int?) /// The batch become obsolete (older than allowed limit for this track's intake). case obsolete /// The batch was deleted due to exceeding allowed max size for batches directory. @@ -77,7 +78,7 @@ internal enum BatchDeletedMetric { func toString() -> String { switch self { case .intakeCode(let responseCode): - return "intake-code-\(responseCode)" + return "intake-code-\(responseCode.map { String($0) } ?? "unknown")" case .obsolete: return "obsolete" case .purged: diff --git a/DatadogCore/Sources/Versioning.swift b/DatadogCore/Sources/Versioning.swift index 8b383f5358..4af31cf490 100644 --- a/DatadogCore/Sources/Versioning.swift +++ b/DatadogCore/Sources/Versioning.swift @@ -1,3 +1,3 @@ // GENERATED FILE: Do not edit directly -internal let __sdkVersion = "2.5.1" +internal let __sdkVersion = "2.6.0" diff --git a/DatadogCore/Tests/Datadog/Core/FeatureTests.swift b/DatadogCore/Tests/Datadog/Core/FeatureTests.swift index c1c4943dfd..6a7f56ce53 100644 --- a/DatadogCore/Tests/Datadog/Core/FeatureTests.swift +++ b/DatadogCore/Tests/Datadog/Core/FeatureTests.swift @@ -45,11 +45,11 @@ class FeatureStorageTests: XCTestCase { // Then storage.setIgnoreFilesAgeWhenReading(to: true) - let batch = try XCTUnwrap(storage.reader.readNextBatch()) + let batch = try XCTUnwrap(storage.reader.readNextBatches(1).first) XCTAssertEqual(batch.events.count, 3, "All 3 events should be written to the same batch") storage.reader.markBatchAsRead(batch) - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batche") + XCTAssertTrue(storage.reader.readNextBatches(1).isEmpty, "There must be no other batches") } func testWhenWritingEventsWithForcingNewBatch_itShouldWriteEachEventToSeparateBatch() throws { @@ -61,19 +61,14 @@ class FeatureStorageTests: XCTestCase { // Then storage.setIgnoreFilesAgeWhenReading(to: true) - var batch = try XCTUnwrap(storage.reader.readNextBatch()) - XCTAssertEqual(batch.events.count, 1) - storage.reader.markBatchAsRead(batch) - - batch = try XCTUnwrap(storage.reader.readNextBatch()) - XCTAssertEqual(batch.events.count, 1) - storage.reader.markBatchAsRead(batch) - - batch = try XCTUnwrap(storage.reader.readNextBatch()) - XCTAssertEqual(batch.events.count, 1) - storage.reader.markBatchAsRead(batch) + let batches = storage.reader.readNextBatches(3) + XCTAssertEqual(batches.count, 3) + batches.forEach { batch in + XCTAssertEqual(batch.events.count, 1) + storage.reader.markBatchAsRead(batch) + } - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batches") + XCTAssertTrue(storage.reader.readNextBatches(1).isEmpty, "There must be no other batches") } // MARK: - Behaviours on tracking consent @@ -87,11 +82,11 @@ class FeatureStorageTests: XCTestCase { // Then storage.setIgnoreFilesAgeWhenReading(to: true) - let batch = try XCTUnwrap(storage.reader.readNextBatch()) + let batch = try XCTUnwrap(storage.reader.readNextBatches(1).first) XCTAssertEqual(batch.events.map { $0.data.utf8String }, [#"{"event.consent":"granted"}"#]) storage.reader.markBatchAsRead(batch) - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batches") + XCTAssertTrue(storage.reader.readNextBatches(1).isEmpty, "There must be no other batches") } func testGivenEventsWrittenInDifferentConsents_whenChangingConsentToGranted_itMakesPendingEventsReadable() throws { @@ -106,15 +101,15 @@ class FeatureStorageTests: XCTestCase { // Then storage.setIgnoreFilesAgeWhenReading(to: true) - var batch = try XCTUnwrap(storage.reader.readNextBatch()) + var batch = try XCTUnwrap(storage.reader.readNextBatches(1).first) XCTAssertEqual(batch.events.map { $0.data.utf8String }, [#"{"event.consent":"granted"}"#]) storage.reader.markBatchAsRead(batch) - batch = try XCTUnwrap(storage.reader.readNextBatch()) + batch = try XCTUnwrap(storage.reader.readNextBatches(1).first) XCTAssertEqual(batch.events.map { $0.data.utf8String }, [#"{"event.consent":"pending"}"#]) storage.reader.markBatchAsRead(batch) - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batches") + XCTAssertTrue(storage.reader.readNextBatches(1).isEmpty, "There must be no other batches") } func testGivenEventsWrittenInDifferentConsents_whenChangingConsentToNotGranted_itDeletesPendingEvents() throws { @@ -129,14 +124,17 @@ class FeatureStorageTests: XCTestCase { // Then storage.setIgnoreFilesAgeWhenReading(to: true) - let batch = try XCTUnwrap(storage.reader.readNextBatch()) + let batch = try XCTUnwrap(storage.reader.readNextBatches(1).first) XCTAssertEqual(batch.events.map { $0.data.utf8String }, [#"{"event.consent":"granted"}"#]) storage.reader.markBatchAsRead(batch) - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batches") + XCTAssertTrue(storage.reader.readNextBatches(1).isEmpty, "There must be no other batches") storage.migrateUnauthorizedData(toConsent: .granted) - XCTAssertNil(storage.reader.readNextBatch(), "There must be no other batches, because pending events were deleted") + XCTAssertTrue( + storage.reader.readNextBatches(1).isEmpty, + "There must be no other batches, because pending events were deleted" + ) } // MARK: - Data migration diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift index 14792ee471..7289d5fee7 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift @@ -83,7 +83,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { // - wait more than batch obsolescence limit // - then request readable file, which should trigger obsolete files deletion dateProvider.advance(bySeconds: storage.maxFileAgeForRead + 1) - _ = orchestrator.getReadableFile() + _ = orchestrator.getReadableFiles() // Then let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "Batch Deleted")) diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestratorTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestratorTests.swift index 4c6979aa22..beed34cfa1 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestratorTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestratorTests.swift @@ -181,36 +181,36 @@ class FilesOrchestratorTests: XCTestCase { // MARK: - Readable file tests - func testGivenNoReadableFiles_whenObtainingFile_itReturnsNil() { + func testGivenNoReadableFiles_whenObtainingFiles_itReturnsEmpty() { let dateProvider = RelativeDateProvider() let orchestrator = configureOrchestrator(using: dateProvider) dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) - XCTAssertNil(orchestrator.getReadableFile()) + XCTAssertTrue(orchestrator.getReadableFiles().isEmpty) } - func testWhenReadableFileIsOldEnough_itReturnsFile() throws { + func testWhenReadableFileIsOldEnough_itReturnsFiles() throws { let dateProvider = RelativeDateProvider() let orchestrator = configureOrchestrator(using: dateProvider) - let file = try orchestrator.directory.createFile(named: dateProvider.now.toFileName) + _ = try orchestrator.directory.createFile(named: dateProvider.now.toFileName) dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) - XCTAssertEqual(orchestrator.getReadableFile()?.name, file.name) + XCTAssertGreaterThan(orchestrator.getReadableFiles().count, 0) } - func testWhenReadableFileIsNotOldEnough_itReturnsNil() throws { + func testWhenReadableFilesAreNotOldEnough_itReturnsEmpty() throws { let dateProvider = RelativeDateProvider() let orchestrator = configureOrchestrator(using: dateProvider) _ = try orchestrator.directory.createFile(named: dateProvider.now.toFileName) dateProvider.advance(bySeconds: 0.5 * performance.minFileAgeForRead) - XCTAssertNil(orchestrator.getReadableFile()) + XCTAssertTrue(orchestrator.getReadableFiles().isEmpty) } - func testWhenThereAreMultipleReadableFiles_itReturnsOldestFile() throws { + func testWhenThereAreMultipleReadableFiles_itReturnsSortedFromOldestFile() throws { let dateProvider = RelativeDateProvider(advancingBySeconds: 1) let orchestrator = configureOrchestrator(using: dateProvider) @@ -218,18 +218,14 @@ class FilesOrchestratorTests: XCTestCase { try fileNames.forEach { fileName in _ = try orchestrator.directory.createFile(named: fileName) } dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) - XCTAssertEqual(orchestrator.getReadableFile()?.name, fileNames[0]) - try orchestrator.directory.file(named: fileNames[0]).delete() - XCTAssertEqual(orchestrator.getReadableFile()?.name, fileNames[1]) - try orchestrator.directory.file(named: fileNames[1]).delete() - XCTAssertEqual(orchestrator.getReadableFile()?.name, fileNames[2]) - try orchestrator.directory.file(named: fileNames[2]).delete() - XCTAssertEqual(orchestrator.getReadableFile()?.name, fileNames[3]) - try orchestrator.directory.file(named: fileNames[3]).delete() - XCTAssertNil(orchestrator.getReadableFile()) + let readableFiles = orchestrator.getReadableFiles() + XCTAssertEqual(readableFiles[0].name, fileNames[0]) + XCTAssertEqual(readableFiles[1].name, fileNames[1]) + XCTAssertEqual(readableFiles[2].name, fileNames[2]) + XCTAssertEqual(readableFiles[3].name, fileNames[3]) } - func testsWhenThereAreMultipleReadableFiles_itReturnsFileByExcludingCertainNames() throws { + func testsWhenThereAreMultipleReadableFiles_itReturnsFilesByExcludingCertainNames() throws { let dateProvider = RelativeDateProvider(advancingBySeconds: 1) let orchestrator = configureOrchestrator(using: dateProvider) @@ -237,33 +233,48 @@ class FilesOrchestratorTests: XCTestCase { try fileNames.forEach { fileName in _ = try orchestrator.directory.createFile(named: fileName) } dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) - XCTAssertEqual( - orchestrator.getReadableFile(excludingFilesNamed: Set(fileNames[0...2]))?.name, - fileNames[3] - ) + let readableFiles = orchestrator.getReadableFiles(excludingFilesNamed: Set(fileNames[0...2])) + XCTAssertEqual(readableFiles.count, 1) + XCTAssertEqual(readableFiles.first?.name, fileNames.last) } - func testWhenReadableFileIsTooOld_itGetsDeleted() throws { + func testWhenReadableFilesAreTooOld_theyGetDeleted() throws { let dateProvider = RelativeDateProvider() let orchestrator = configureOrchestrator(using: dateProvider) _ = try orchestrator.directory.createFile(named: dateProvider.now.toFileName) dateProvider.advance(bySeconds: 2 * performance.maxFileAgeForRead) - XCTAssertNil(orchestrator.getReadableFile()) + XCTAssertTrue(orchestrator.getReadableFiles().isEmpty) XCTAssertEqual(try orchestrator.directory.files().count, 0) } + func testWhenThereAreMultipleReadableFiles_itRespectsTheLimit() throws { + let dateProvider = RelativeDateProvider(advancingBySeconds: 1) + let orchestrator = configureOrchestrator(using: dateProvider) + + let fileNames = (0..<4).map { _ in dateProvider.now.toFileName } + try fileNames.forEach { fileName in _ = try orchestrator.directory.createFile(named: fileName) } + + dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) + let limit = 2 + let readableFiles = orchestrator.getReadableFiles(limit: limit) + + XCTAssertEqual(readableFiles.count, limit) + XCTAssertEqual(readableFiles[0].name, fileNames[0]) + XCTAssertEqual(readableFiles[1].name, fileNames[1]) + } + // MARK: - Deleting Files - func testItDeletesReadableFile() throws { + func testItDeletesReadableFiles() throws { let dateProvider = RelativeDateProvider() let orchestrator = configureOrchestrator(using: dateProvider) _ = try orchestrator.directory.createFile(named: dateProvider.now.toFileName) dateProvider.advance(bySeconds: 1 + performance.minFileAgeForRead) - let readableFile = try orchestrator.getReadableFile().unwrapOrThrow() + let readableFile = try orchestrator.getReadableFiles().first.unwrapOrThrow() XCTAssertEqual(try orchestrator.directory.files().count, 1) orchestrator.delete(readableFile: readableFile) XCTAssertEqual(try orchestrator.directory.files().count, 0) @@ -304,11 +315,3 @@ class FilesOrchestratorTests: XCTestCase { } // swiftlint:enable number_separator } - -extension FilesOrchestrator { - func getReadableFile( - context: DatadogContext - ) -> ReadableFile? { - getReadableFile(excludingFilesNamed: []) - } -} diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/Reading/FileReaderTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/Reading/FileReaderTests.swift index 6480fe8e87..3dc43b5ff2 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/Reading/FileReaderTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/Reading/FileReaderTests.swift @@ -22,7 +22,7 @@ class FileReaderTests: XCTestCase { super.tearDown() } - func testItReadsSingleBatch() throws { + func testItReadsBatches() throws { let reader = FileReader( orchestrator: FilesOrchestrator( directory: directory, @@ -33,6 +33,7 @@ class FileReaderTests: XCTestCase { encryption: nil, telemetry: NOPTelemetry() ) + let dataProvider = RelativeDateProvider() let dataBlocks = [ DataBlock(type: .eventMetadata, data: "EFGH".utf8Data), DataBlock(type: .event, data: "ABCD".utf8Data) @@ -41,20 +42,29 @@ class FileReaderTests: XCTestCase { .map { try $0.serialize() } .reduce(.init(), +) _ = try directory - .createFile(named: Date.mockAny().toFileName) + .createFile(named: dataProvider.now.toFileName) .append(data: data) XCTAssertEqual(try directory.files().count, 1) - let batch = reader.readNextBatch() + XCTAssertEqual(reader.readNextBatches(.max).count, 1) + let batch = reader.readNextBatches(1).first let expected = [ Event(data: "ABCD".utf8Data, metadata: "EFGH".utf8Data) ] XCTAssertEqual(batch?.events, expected) + + dataProvider.advance(bySeconds: .mockRandom()) + _ = try directory + .createFile(named: dataProvider.now.toFileName) + .append(data: data) + + XCTAssertEqual(try directory.files().count, 2) + XCTAssertEqual(reader.readNextBatches(2).count, 2) + XCTAssertEqual(reader.readNextBatches(.max).count, 2) } - func testItReadsSingleEncryptedBatch() throws { - // Given + func testItReadsEncryptedBatches() throws { let dataBlocks = [ DataBlock(type: .eventMetadata, data: "foo".utf8Data), DataBlock(type: .event, data: "foo".utf8Data), @@ -66,8 +76,10 @@ class FileReaderTests: XCTestCase { .map { Data(try $0.serialize()) } .reduce(.init(), +) + let dataProvider = RelativeDateProvider() + _ = try directory - .createFile(named: Date.mockAny().toFileName) + .createFile(named: dataProvider.now.toFileName) .append(data: data) let reader = FileReader( @@ -83,16 +95,23 @@ class FileReaderTests: XCTestCase { telemetry: NOPTelemetry() ) - // When - let batch = reader.readNextBatch() + XCTAssertEqual(reader.readNextBatches(.max).count, 1) + let batch = reader.readNextBatches(1).first - // Then let expected = [ Event(data: "bar".utf8Data, metadata: "bar".utf8Data), Event(data: "bar".utf8Data, metadata: nil), Event(data: "bar".utf8Data, metadata: "bar".utf8Data) ] XCTAssertEqual(batch?.events, expected) + + dataProvider.advance(bySeconds: .mockRandom()) + _ = try directory + .createFile(named: dataProvider.now.toFileName) + .append(data: data) + + XCTAssertEqual(reader.readNextBatches(2).count, 2) + XCTAssertEqual(reader.readNextBatches(.max).count, 2) } func testItMarksBatchesAsRead() throws { @@ -124,20 +143,23 @@ class FileReaderTests: XCTestCase { Event(data: "3".utf8Data, metadata: "4".utf8Data) ] - var batch: Batch - batch = try reader.readNextBatch().unwrapOrThrow() + let batch: Batch + batch = try reader.readNextBatches(1).first.unwrapOrThrow() XCTAssertEqual(batch.events.first, expected[0]) reader.markBatchAsRead(batch) - batch = try reader.readNextBatch().unwrapOrThrow() - XCTAssertEqual(batch.events.first, expected[1]) - reader.markBatchAsRead(batch) - - batch = try reader.readNextBatch().unwrapOrThrow() - XCTAssertEqual(batch.events.first, expected[2]) - reader.markBatchAsRead(batch) + let batches = reader.readNextBatches(2) + XCTAssertEqual(batches[0].events.first, expected[1]) + XCTAssertEqual(batches[1].events.first, expected[2]) + batches.forEach { reader.markBatchAsRead($0) } - XCTAssertNil(reader.readNextBatch()) + XCTAssertTrue(reader.readNextBatches(1).isEmpty) XCTAssertEqual(try directory.files().count, 0) } } + +extension Reader { + func readNextBatches(_ limit: Int = .max) -> [Batch] { + return readFiles(limit: limit).compactMap { readBatch(from: $0) } + } +} diff --git a/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift b/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift index 40e0943043..986af5967e 100644 --- a/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift @@ -66,7 +66,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: DataUploadConditions.alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuick), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: 1 ) // Then @@ -79,6 +80,43 @@ class DataUploadWorkerTests: XCTestCase { XCTAssertEqual(try orchestrator.directory.files().count, 0) } + func testItUploadsDataSequentiallyWithoutDelay_whenMaxBatchesPerUploadIsSet() { + let uploadExpectation = self.expectation(description: "Make 2 uploads") + uploadExpectation.expectedFulfillmentCount = 2 + + let dataUploader = DataUploaderMock( + uploadStatus: DataUploadStatus(httpResponse: .mockResponseWith(statusCode: 200), ddRequestID: nil), + onUpload: uploadExpectation.fulfill + ) + + // Given + writer.write(value: ["k1": "v1"]) + writer.write(value: ["k2": "v2"]) + writer.write(value: ["k3": "v3"]) + + // When + let worker = DataUploadWorker( + queue: uploaderQueue, + fileReader: reader, + dataUploader: dataUploader, + contextProvider: .mockAny(), + uploadConditions: DataUploadConditions.alwaysUpload(), + delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), + featureName: .mockAny(), + telemetry: NOPTelemetry(), + maxBatchesPerUpload: 2 + ) + + // Then + waitForExpectations(timeout: 1) + XCTAssertEqual(dataUploader.uploadedEvents.count, 2) + XCTAssertEqual(dataUploader.uploadedEvents[0], Event(data: #"{"k1":"v1"}"#.utf8Data)) + XCTAssertEqual(dataUploader.uploadedEvents[1], Event(data: #"{"k2":"v2"}"#.utf8Data)) + + worker.cancelSynchronously() + XCTAssertEqual(try orchestrator.directory.files().count, 1) + } + func testGivenDataToUpload_whenUploadFinishesAndDoesNotNeedToBeRetried_thenDataIsDeleted() { let startUploadExpectation = self.expectation(description: "Upload has started") @@ -98,7 +136,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -130,7 +169,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [initiatingUploadExpectation], timeout: 0.5) @@ -159,7 +199,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -194,7 +235,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: DataUploadConditions.neverUpload(), delay: delay, featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) // Then @@ -207,8 +249,13 @@ class DataUploadWorkerTests: XCTestCase { worker.cancelSynchronously() } - func testWhenBatchFails_thenIntervalIncreases() { + func testWhenBatchFails_thenIntervalIncreasesAndUploadCycleEnds() { let delayChangeExpectation = expectation(description: "Upload delay is increased") + delayChangeExpectation.expectedFulfillmentCount = 1 + + let uploadAttemptExpectation = expectation(description: "Upload was attempted") + uploadAttemptExpectation.expectedFulfillmentCount = 1 + let initialUploadDelay = 0.01 let delay = DataUploadDelay( performance: UploadPerformanceMock( @@ -221,16 +268,28 @@ class DataUploadWorkerTests: XCTestCase { // When writer.write(value: ["k1": "v1"]) + writer.write(value: ["k2": "v2"]) + writer.write(value: ["k3": "v3"]) + + let dataUploader = DataUploaderMock( + uploadStatus: .mockWith( + needsRetry: true, + error: .httpError(statusCode: 500) + ) + ) { + uploadAttemptExpectation.fulfill() + } let worker = DataUploadWorker( queue: uploaderQueue, fileReader: reader, - dataUploader: DataUploaderMock(uploadStatus: .mockWith(needsRetry: true)), + dataUploader: dataUploader, contextProvider: .mockAny(), uploadConditions: DataUploadConditions.alwaysUpload(), delay: delay, featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) // Then @@ -239,7 +298,7 @@ class DataUploadWorkerTests: XCTestCase { delay.current > initialUploadDelay } }, andThenFulfill: delayChangeExpectation) - wait(for: [delayChangeExpectation], timeout: 0.5) + wait(for: [delayChangeExpectation, uploadAttemptExpectation], timeout: 0.5) worker.cancelSynchronously() } @@ -265,7 +324,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: DataUploadConditions.alwaysUpload(), delay: delay, featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) // Then @@ -303,7 +363,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: randomFeatureName, - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -315,7 +376,7 @@ class DataUploadWorkerTests: XCTestCase { XCTAssertEqual( dd.logger.debugLogs[0].message, - "⏳ (\(randomFeatureName)) Uploading batch...", + "⏳ (\(randomFeatureName)) Uploading batches...", "Batch start information should be printed to `userLogger`. All captured logs:\n\(dd.logger.recordedLogs)" ) @@ -348,7 +409,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockRandom(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -382,7 +444,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockRandom(), - telemetry: telemetry + telemetry: telemetry, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -415,7 +478,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockRandom(), - telemetry: telemetry + telemetry: telemetry, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [startUploadExpectation], timeout: 0.5) @@ -450,7 +514,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: .alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: "some-feature", - telemetry: telemetry + telemetry: telemetry, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) wait(for: [initiatingUploadExpectation], timeout: 0.5) @@ -482,7 +547,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: DataUploadConditions.neverUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuick), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) // When @@ -510,7 +576,8 @@ class DataUploadWorkerTests: XCTestCase { uploadConditions: DataUploadConditions.alwaysUpload(), delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuick), featureName: .mockAny(), - telemetry: NOPTelemetry() + telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100) ) // Given @@ -551,6 +618,7 @@ class DataUploadWorkerTests: XCTestCase { delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuick), featureName: .mockAny(), telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTaskCoordinator: backgroundTaskCoordinator ) writer.write(value: ["k1": "v1"]) @@ -580,6 +648,7 @@ class DataUploadWorkerTests: XCTestCase { delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuick), featureName: .mockAny(), telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTaskCoordinator: backgroundTaskCoordinator ) writer.write(value: ["k1": "v1"]) @@ -608,6 +677,7 @@ class DataUploadWorkerTests: XCTestCase { delay: DataUploadDelay(performance: UploadPerformanceMock.veryQuickInitialUpload), featureName: .mockAny(), telemetry: NOPTelemetry(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTaskCoordinator: backgroundTaskCoordinator ) // Then diff --git a/DatadogCore/Tests/Datadog/DatadogConfigurationTests.swift b/DatadogCore/Tests/Datadog/DatadogConfigurationTests.swift index bcdfa5a4cd..173288f1ba 100644 --- a/DatadogCore/Tests/Datadog/DatadogConfigurationTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogConfigurationTests.swift @@ -79,6 +79,7 @@ class DatadogConfigurationTests: XCTestCase { configuration.site = .eu1 configuration.batchSize = .small configuration.uploadFrequency = .frequent + configuration.batchProcessingLevel = .high configuration.proxyConfiguration = [ kCFNetworkProxiesHTTPEnable: true, kCFNetworkProxiesHTTPPort: 123, @@ -103,6 +104,7 @@ class DatadogConfigurationTests: XCTestCase { XCTAssertEqual(configuration.batchSize, .small) XCTAssertEqual(configuration.uploadFrequency, .frequent) + XCTAssertEqual(configuration.batchProcessingLevel, .high) XCTAssertTrue(configuration.encryption is DataEncryptionMock) XCTAssertTrue(configuration.serverDateProvider is ServerDateProviderMock) @@ -360,4 +362,19 @@ class DatadogConfigurationTests: XCTestCase { XCTAssertEqual(context.version, "5.23.2") } + + func testGivenBuildId_itSetsContext() throws { + // Given + let buildId: String = .mockRandom(length: 32) + var configuration = defaultConfig + configuration.additionalConfiguration[CrossPlatformAttributes.buildId] = buildId + + Datadog.initialize(with: configuration, trackingConsent: .mockRandom()) + defer { Datadog.flushAndDeinitialize() } + + let core = try XCTUnwrap(CoreRegistry.default as? DatadogCore) + let context = core.contextProvider.read() + + XCTAssertEqual(context.buildId, buildId) + } } diff --git a/DatadogCore/Tests/Datadog/DatadogCore/Context/FeatureContextTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/Context/FeatureContextTests.swift index 265c3e7884..608e313b35 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/Context/FeatureContextTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/Context/FeatureContextTests.swift @@ -21,6 +21,7 @@ class FeatureContextTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) diff --git a/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift index 056d7fb899..e0865648c7 100644 --- a/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift +++ b/DatadogCore/Tests/Datadog/DatadogCore/DatadogCoreTests.swift @@ -43,6 +43,7 @@ class DatadogCoreTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } @@ -89,6 +90,7 @@ class DatadogCoreTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } @@ -143,6 +145,7 @@ class DatadogCoreTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } @@ -194,6 +197,7 @@ class DatadogCoreTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) let core2 = DatadogCore( @@ -205,6 +209,7 @@ class DatadogCoreTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { diff --git a/DatadogCore/Tests/Datadog/Logs/CrashLogReceiverTests.swift b/DatadogCore/Tests/Datadog/Logs/CrashLogReceiverTests.swift index cd868a2a99..26f8a7f2e9 100644 --- a/DatadogCore/Tests/Datadog/Logs/CrashLogReceiverTests.swift +++ b/DatadogCore/Tests/Datadog/Logs/CrashLogReceiverTests.swift @@ -145,6 +145,7 @@ class CrashLogReceiverTests: XCTestCase { threadName: nil, applicationVersion: crashContext.version, applicationBuildNumber: crashContext.buildNumber, + buildId: nil, dd: .init(device: .init(architecture: mockArchitecture)), os: .init( name: mockOSName, diff --git a/DatadogCore/Tests/Datadog/Logs/DatadogLogsFeatureTests.swift b/DatadogCore/Tests/Datadog/Logs/DatadogLogsFeatureTests.swift index 81584ddd3d..0f55031a29 100644 --- a/DatadogCore/Tests/Datadog/Logs/DatadogLogsFeatureTests.swift +++ b/DatadogCore/Tests/Datadog/Logs/DatadogLogsFeatureTests.swift @@ -67,6 +67,7 @@ class DatadogLogsFeatureTests: XCTestCase { ) ), applicationVersion: randomApplicationVersion, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: randomBackgroundTasksEnabled ) defer { core.flushAndTearDown() } @@ -129,6 +130,7 @@ class DatadogLogsFeatureTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } diff --git a/DatadogCore/Tests/Datadog/Mocks/CoreMocks.swift b/DatadogCore/Tests/Datadog/Mocks/CoreMocks.swift index 8c29385796..98395e89b3 100644 --- a/DatadogCore/Tests/Datadog/Mocks/CoreMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/CoreMocks.swift @@ -259,7 +259,8 @@ extension FilesOrchestratorType { } class NOPReader: Reader { - func readNextBatch() -> Batch? { nil } + func readFiles(limit: Int) -> [ReadableFile] { [] } + func readBatch(from file: ReadableFile) -> Batch? { nil } func markBatchAsRead(_ batch: Batch, reason: BatchDeletedMetric.RemovalReason) {} } @@ -276,7 +277,7 @@ internal class NOPFilesOrchestrator: FilesOrchestratorType { func getNewWritableFile(writeSize: UInt64) throws -> WritableFile { NOPFile() } func getWritableFile(writeSize: UInt64) throws -> WritableFile { NOPFile() } - func getReadableFile(excludingFilesNamed excludedFileNames: Set) -> ReadableFile? { NOPFile() } + func getReadableFiles(excludingFilesNamed excludedFileNames: Set, limit: Int) -> [ReadableFile] { [] } func delete(readableFile: ReadableFile, deletionReason: BatchDeletedMetric.RemovalReason) { } var ignoreFilesAgeWhenReading = false diff --git a/DatadogCore/Tests/Datadog/Mocks/DatadogInternal/DatadogCoreProxy.swift b/DatadogCore/Tests/Datadog/Mocks/DatadogInternal/DatadogCoreProxy.swift index 105f271435..f2b3f61979 100644 --- a/DatadogCore/Tests/Datadog/Mocks/DatadogInternal/DatadogCoreProxy.swift +++ b/DatadogCore/Tests/Datadog/Mocks/DatadogInternal/DatadogCoreProxy.swift @@ -46,6 +46,7 @@ internal class DatadogCoreProxy: DatadogCoreProtocol { encryption: nil, contextProvider: DatadogContextProvider(context: context), applicationVersion: context.version, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) @@ -148,8 +149,8 @@ private class FeatureScopeInterceptor { func enter() { group.enter() } func leave() { group.leave() } - func waitAndReturnEvents() -> [(event: Any, data: Data)] { - _ = group.wait(timeout: .distantFuture) + func waitAndReturnEvents(timeout: DispatchTime) -> [(event: Any, data: Data)] { + _ = group.wait(timeout: timeout) return events } } @@ -160,19 +161,19 @@ extension DatadogCoreProxy { /// - name: The Feature to retrieve events from /// - type: The type of events to filter out /// - Returns: A list of events. - func waitAndReturnEvents(ofFeature name: String, ofType type: T.Type) -> [T] where T: Encodable { + func waitAndReturnEvents(ofFeature name: String, ofType type: T.Type, timeout: DispatchTime = .distantFuture) -> [T] where T: Encodable { flush() let interceptor = self.featureScopeInterceptors[name]! - return interceptor.waitAndReturnEvents().compactMap { $0.event as? T } + return interceptor.waitAndReturnEvents(timeout: timeout).compactMap { $0.event as? T } } /// Returns serialized events of given Feature. /// /// - Parameter feature: The Feature to retrieve events from /// - Returns: A list of serialized events. - func waitAndReturnEventsData(ofFeature name: String) -> [Data] { + func waitAndReturnEventsData(ofFeature name: String, timeout: DispatchTime = .distantFuture) -> [Data] { flush() let interceptor = self.featureScopeInterceptors[name]! - return interceptor.waitAndReturnEvents().map { $0.data } + return interceptor.waitAndReturnEvents(timeout: timeout).map { $0.data } } } diff --git a/DatadogCore/Tests/Datadog/Mocks/LogsMocks.swift b/DatadogCore/Tests/Datadog/Mocks/LogsMocks.swift index cea97d8784..2d66d1b38f 100644 --- a/DatadogCore/Tests/Datadog/Mocks/LogsMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/LogsMocks.swift @@ -101,6 +101,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: String = .mockAny(), applicationVersion: String = .mockAny(), applicationBuildNumber: String = .mockAny(), + buildId: String? = .mockAny(), dd: LogEvent.Dd = .mockAny(), os: LogEvent.OperatingSystem = .mockAny(), userInfo: UserInfo = .mockAny(), @@ -121,6 +122,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: threadName, applicationVersion: applicationVersion, applicationBuildNumber: applicationBuildNumber, + buildId: buildId, dd: dd, os: os, userInfo: userInfo, @@ -144,6 +146,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: .mockRandom(), applicationVersion: .mockRandom(), applicationBuildNumber: .mockRandom(), + buildId: .mockRandom(), dd: .mockRandom(), os: .mockRandom(), userInfo: .mockRandom(), diff --git a/DatadogCore/Tests/Datadog/Mocks/RUMDataModelMocks.swift b/DatadogCore/Tests/Datadog/Mocks/RUMDataModelMocks.swift index ee320614f2..335f7310e8 100644 --- a/DatadogCore/Tests/Datadog/Mocks/RUMDataModelMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/RUMDataModelMocks.swift @@ -38,6 +38,12 @@ extension RUMMethod: RandomMockable { } } +extension RUMSessionPrecondition: RandomMockable { + public static func mockRandom() -> RUMSessionPrecondition { + return [.userAppLaunch, .inactivityTimeout, .maxDuration, .backgroundLaunch, .prewarm, .fromNonInteractiveSession, .explicitStop].randomElement()! + } +} + extension RUMEventAttributes: RandomMockable { public static func mockRandom() -> RUMEventAttributes { return .init(contextInfo: mockRandomAttributes()) @@ -96,7 +102,11 @@ extension RUMOperatingSystem: RandomMockable { extension RUMViewEvent.DD.Configuration: RandomMockable { public static func mockRandom() -> RUMViewEvent.DD.Configuration { - return .init(sessionReplaySampleRate: .mockRandom(min: 0, max: 100), sessionSampleRate: .mockRandom(min: 0, max: 100)) + return .init( + sessionReplaySampleRate: .mockRandom(min: 0, max: 100), + sessionSampleRate: .mockRandom(min: 0, max: 100), + startSessionReplayRecordingManually: nil + ) } } @@ -118,12 +128,17 @@ extension RUMViewEvent: RandomMockable { documentVersion: .mockRandom(), pageStates: nil, replayStats: nil, - session: .init(plan: .plan1) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -136,7 +151,6 @@ extension RUMViewEvent: RandomMockable { id: .mockRandom(), isActive: true, sampledForReplay: nil, - startPrecondition: .appLaunch, type: .user ), source: .ios, @@ -210,15 +224,20 @@ extension RUMResourceEvent: RandomMockable { configuration: .mockRandom(), discarded: nil, rulePsr: nil, - session: .init(plan: .plan1), + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ), spanId: .mockRandom(), traceId: .mockRandom() ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -283,7 +302,10 @@ extension RUMActionEvent: RandomMockable { ), browserSdkVersion: nil, configuration: .mockRandom(), - session: .init(plan: .plan1) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init( crash: .init(count: .mockRandom()), @@ -297,9 +319,11 @@ extension RUMActionEvent: RandomMockable { type: [.tap, .swipe, .scroll].randomElement()! ), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -343,13 +367,18 @@ extension RUMErrorEvent: RandomMockable { dd: .init( browserSdkVersion: nil, configuration: .mockRandom(), - session: .init(plan: .plan1) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -422,13 +451,18 @@ extension RUMLongTaskEvent: RandomMockable { browserSdkVersion: nil, configuration: .mockRandom(), discarded: nil, - session: .init(plan: .plan1) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -466,6 +500,8 @@ extension TelemetryConfigurationEvent: RandomMockable { actionNameAttribute: nil, allowFallbackToLocalStorage: nil, allowUntrustedEvents: nil, + backgroundTasksEnabled: .mockRandom(), + batchProcessingLevel: .mockRandom(), batchSize: .mockAny(), batchUploadFrequency: .mockAny(), defaultPrivacyLevel: .mockAny(), diff --git a/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift b/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift index 60d07507ce..05c6bbf415 100644 --- a/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift @@ -60,6 +60,7 @@ extension CrashReportReceiver: AnyMockable { trackBackgroundEvents: Bool = true, uuidGenerator: RUMUUIDGenerator = DefaultRUMUUIDGenerator(), ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, telemetry: Telemetry = NOPTelemetry() ) -> Self { .init( @@ -69,6 +70,7 @@ extension CrashReportReceiver: AnyMockable { trackBackgroundEvents: trackBackgroundEvents, uuidGenerator: uuidGenerator, ciTest: ciTest, + syntheticsTest: syntheticsTest, telemetry: telemetry ) } @@ -126,6 +128,19 @@ extension RUMEventsMapper { // MARK: - RUMCommand Mocks +/// Holds the `mockView` object so it can be weakly referenced by `RUMViewScope` mocks. +let mockView: UIViewController = createMockViewInWindow() + +extension ViewIdentifier { + static func mockViewIdentifier() -> ViewIdentifier { + ViewIdentifier(mockView) + } + + static func mockRandomString() -> ViewIdentifier { + ViewIdentifier(String.mockRandom()) + } +} + struct RUMCommandMock: RUMCommand { var time = Date() var attributes: [AttributeKey: AttributeValue] = [:] @@ -168,7 +183,7 @@ extension RUMStartViewCommand: AnyMockable, RandomMockable { return .mockWith( time: .mockRandomInThePast(), attributes: mockRandomAttributes(), - identity: String.mockRandom().asRUMViewIdentity(), + identity: .mockRandomString(), name: .mockRandom(), path: .mockRandom() ) @@ -177,9 +192,9 @@ extension RUMStartViewCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], - identity: RUMViewIdentity = mockViewIdentity, + identity: ViewIdentifier = .mockViewIdentifier(), name: String = .mockAny(), - path: String? = nil + path: String = .mockAny() ) -> RUMStartViewCommand { return RUMStartViewCommand( time: time, @@ -198,14 +213,14 @@ extension RUMStopViewCommand: AnyMockable, RandomMockable { return .mockWith( time: .mockRandomInThePast(), attributes: mockRandomAttributes(), - identity: String.mockRandom().asRUMViewIdentity() + identity: .mockRandomString() ) } static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], - identity: RUMViewIdentity = mockViewIdentity + identity: ViewIdentifier = .mockViewIdentifier() ) -> RUMStopViewCommand { return RUMStopViewCommand( time: time, attributes: attributes, identity: identity @@ -681,6 +696,7 @@ extension RUMScopeDependencies { eventBuilder: RUMEventBuilder = RUMEventBuilder(eventsMapper: .mockNoOp()), rumUUIDGenerator: RUMUUIDGenerator = DefaultRUMUUIDGenerator(), ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, vitalsReaders: VitalsReaders? = nil, onSessionStart: @escaping RUM.SessionListener = mockNoOpSessionListener() ) -> RUMScopeDependencies { @@ -694,6 +710,7 @@ extension RUMScopeDependencies { eventBuilder: eventBuilder, rumUUIDGenerator: rumUUIDGenerator, ciTest: ciTest, + syntheticsTest: syntheticsTest, vitalsReaders: vitalsReaders, onSessionStart: onSessionStart ) @@ -709,6 +726,7 @@ extension RUMScopeDependencies { eventBuilder: RUMEventBuilder? = nil, rumUUIDGenerator: RUMUUIDGenerator? = nil, ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, vitalsReaders: VitalsReaders? = nil, onSessionStart: RUM.SessionListener? = nil ) -> RUMScopeDependencies { @@ -722,6 +740,7 @@ extension RUMScopeDependencies { eventBuilder: eventBuilder ?? self.eventBuilder, rumUUIDGenerator: rumUUIDGenerator ?? self.rumUUIDGenerator, ciTest: ciTest ?? self.ciTest, + syntheticsTest: syntheticsTest ?? self.syntheticsTest, vitalsReaders: vitalsReaders ?? self.vitalsReaders, onSessionStart: onSessionStart ?? self.onSessionStart ) @@ -744,6 +763,7 @@ extension RUMSessionScope { isInitialSession: Bool = .mockAny(), parent: RUMContextProvider = RUMContextProviderMock(), startTime: Date = .mockAny(), + startPrecondition: RUMSessionPrecondition? = .userAppLaunch, dependencies: RUMScopeDependencies = .mockAny(), hasReplay: Bool? = .mockAny() ) -> RUMSessionScope { @@ -751,6 +771,7 @@ extension RUMSessionScope { isInitialSession: isInitialSession, parent: parent, startTime: startTime, + startPrecondition: startPrecondition, dependencies: dependencies, hasReplay: hasReplay ) @@ -784,10 +805,6 @@ func createMockView(viewControllerClassName: String) -> UIViewController { return viewController } -///// Holds the `mockView` object so it can be weakly referenced by `RUMViewScope` mocks. -let mockView: UIViewController = createMockViewInWindow() -let mockViewIdentity = mockView.asRUMViewIdentity() - extension RUMViewScope { static func mockAny() -> RUMViewScope { return mockWith() @@ -803,7 +820,7 @@ extension RUMViewScope { isInitialView: Bool = false, parent: RUMContextProvider = RUMContextProviderMock(), dependencies: RUMScopeDependencies = .mockAny(), - identity: RUMViewIdentity = mockViewIdentity, + identity: ViewIdentifier = .mockViewIdentifier(), path: String = .mockAny(), name: String = .mockAny(), attributes: [AttributeKey: AttributeValue] = [:], diff --git a/DatadogCore/Tests/Datadog/RUM/RUMDebuggingTests.swift b/DatadogCore/Tests/Datadog/RUM/RUMDebuggingTests.swift index b3ca57449d..d3e4b4d31b 100644 --- a/DatadogCore/Tests/Datadog/RUM/RUMDebuggingTests.swift +++ b/DatadogCore/Tests/Datadog/RUM/RUMDebuggingTests.swift @@ -22,7 +22,7 @@ class RUMDebuggingTests: XCTestCase { dependencies: .mockWith(rumApplicationID: "rum-123") ) _ = applicationScope.process( - command: RUMStartViewCommand.mockWith(identity: mockViewIdentity, name: "FirstView"), + command: RUMStartViewCommand.mockWith(identity: .mockViewIdentifier(), name: "FirstView"), context: .mockAny(), writer: FileWriterMock() ) @@ -56,7 +56,7 @@ class RUMDebuggingTests: XCTestCase { dependencies: .mockWith(rumApplicationID: "rum-123") ) _ = applicationScope.process( - command: RUMStartViewCommand.mockWith(identity: mockViewIdentity, name: "FirstView"), + command: RUMStartViewCommand.mockWith(identity: .mockViewIdentifier(), name: "FirstView"), context: context, writer: writer ) @@ -66,7 +66,7 @@ class RUMDebuggingTests: XCTestCase { writer: writer ) _ = applicationScope.process( - command: RUMStartViewCommand.mockWith(identity: mockViewIdentity, name: "SecondView"), + command: RUMStartViewCommand.mockWith(identity: .mockViewIdentifier(), name: "SecondView"), context: context, writer: writer ) diff --git a/DatadogCore/Tests/Datadog/RUM/RUMFeatureTests.swift b/DatadogCore/Tests/Datadog/RUM/RUMFeatureTests.swift index 80d44407f1..f1d36ec394 100644 --- a/DatadogCore/Tests/Datadog/RUM/RUMFeatureTests.swift +++ b/DatadogCore/Tests/Datadog/RUM/RUMFeatureTests.swift @@ -71,6 +71,7 @@ class RUMFeatureTests: XCTestCase { ) ), applicationVersion: randomApplicationVersion, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: randomBackgroundTasksEnabled ) defer { core.flushAndTearDown() } @@ -124,7 +125,7 @@ class RUMFeatureTests: XCTestCase { maxFileAgeForWrite: .distantFuture, // write all events to single file, minFileAgeForRead: StoragePerformanceMock.readAllFiles.minFileAgeForRead, maxFileAgeForRead: StoragePerformanceMock.readAllFiles.maxFileAgeForRead, - maxObjectsInFile: 3, // write 3 spans to payload, + maxObjectsInFile: .max, maxObjectSize: .max ), uploadPerformance: UploadPerformanceMock( @@ -138,6 +139,7 @@ class RUMFeatureTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } @@ -145,23 +147,68 @@ class RUMFeatureTests: XCTestCase { // Given RUM.enable(with: .mockAny(), in: core) - core.scope(for: RUMFeature.name)?.eventWriteContext { _, writer in - writer.write(value: RUMDataModelMock(attribute: "1st event"), metadata: RUMViewEvent.Metadata(id: "1", documentVersion: 1)) - writer.write(value: RUMDataModelMock(attribute: "2nd event"), metadata: RUMViewEvent.Metadata(id: "2", documentVersion: 1)) - writer.write(value: RUMDataModelMock(attribute: "3rd event"), metadata: RUMViewEvent.Metadata(id: "1", documentVersion: 2)) - } - let payload = try XCTUnwrap(server.waitAndReturnRequests(count: 1)[0].httpBody) // Expected payload format: - // event1JSON is skipped in favor of event3JSON which is same event with higher document revision // ``` - // event2JSON - // event3JSON + // view event JSON - "application launch" view + // action event JSON - "application start" action // ``` let eventMatchers = try RUMEventMatcher.fromNewlineSeparatedJSONObjectsData(payload) - XCTAssertEqual((try eventMatchers[0].model() as RUMDataModelMock).attribute, "2nd event") - XCTAssertEqual((try eventMatchers[1].model() as RUMDataModelMock).attribute, "3rd event") + XCTAssertFalse(eventMatchers.filterRUMEvents(ofType: RUMViewEvent.self).isEmpty, "It must include view event") + XCTAssertFalse(eventMatchers.filterRUMEvents(ofType: RUMActionEvent.self).isEmpty, "It must include action event") + } + + func testItOnlyKeepsOneViewEventPerPayload() throws { + let server = ServerMock(delivery: .success(response: .mockResponseWith(statusCode: 200))) + let httpClient = URLSessionClient(session: server.getInterceptedURLSession()) + + let core = DatadogCore( + directory: temporaryCoreDirectory, + dateProvider: SystemDateProvider(), + initialConsent: .granted, + performance: .combining( + storagePerformance: StoragePerformanceMock( + maxFileSize: .max, + maxDirectorySize: .max, + maxFileAgeForWrite: .distantFuture, // write all events to single file, + minFileAgeForRead: StoragePerformanceMock.readAllFiles.minFileAgeForRead, + maxFileAgeForRead: StoragePerformanceMock.readAllFiles.maxFileAgeForRead, + maxObjectsInFile: .max, + maxObjectSize: .max + ), + uploadPerformance: UploadPerformanceMock( + initialUploadDelay: 0.5, // wait enough until events are written, + minUploadDelay: 1, + maxUploadDelay: 1, + uploadDelayChangeRate: 0 + ) + ), + httpClient: httpClient, + encryption: nil, + contextProvider: .mockAny(), + applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), + backgroundTasksEnabled: .mockAny() + ) + defer { core.flushAndTearDown() } + + // Given + RUM.enable(with: .mockAny(), in: core) + + // When + RUMMonitor.shared(in: core).addError(message: "1st error") + RUMMonitor.shared(in: core).addError(message: "2nd error") + RUMMonitor.shared(in: core).addError(message: "3rd error") + + // Then + let payload = try XCTUnwrap(server.waitAndReturnRequests(count: 1)[0].httpBody) + let eventMatchers = try RUMEventMatcher.fromNewlineSeparatedJSONObjectsData(payload) + let viewMatchers = eventMatchers.filterRUMEvents(ofType: RUMViewEvent.self) + XCTAssertEqual(viewMatchers.count, 1, "It should keep only one view event") + try viewMatchers[0].model(ofType: RUMViewEvent.self) { event in + XCTAssertEqual(event.view.error.count, 3, "It should track 3 errors") + } } } diff --git a/DatadogCore/Tests/Datadog/RUM/RUMInternalProxyTests.swift b/DatadogCore/Tests/Datadog/RUM/RUMInternalProxyTests.swift index 5e4f3c17dc..8109174a30 100644 --- a/DatadogCore/Tests/Datadog/RUM/RUMInternalProxyTests.swift +++ b/DatadogCore/Tests/Datadog/RUM/RUMInternalProxyTests.swift @@ -41,8 +41,9 @@ class RUMInternalProxyTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() // Then - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let longTask = session.viewVisits[0].longTaskEvents.first + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let longTask = views[0].longTaskEvents.first XCTAssertEqual(longTask?.date, (date - duration).timeIntervalSince1970.toInt64Milliseconds) XCTAssertEqual(longTask?.longTask.duration, duration.toInt64Nanoseconds) } @@ -114,8 +115,10 @@ class RUMInternalProxyTests: XCTestCase { // Then let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + + let resourceEvent = views[0].resourceEvents[0] XCTAssertEqual(resourceEvent.resource.type, .native, "POST Resources should always have the `.native` kind") XCTAssertEqual(resourceEvent.resource.statusCode, 200) diff --git a/DatadogCore/Tests/Datadog/RUM/RUMMonitorTests.swift b/DatadogCore/Tests/Datadog/RUM/RUMMonitorTests.swift index 0cd84e5ec0..6582b8cef7 100644 --- a/DatadogCore/Tests/Datadog/RUM/RUMMonitorTests.swift +++ b/DatadogCore/Tests/Datadog/RUM/RUMMonitorTests.swift @@ -30,6 +30,70 @@ class RUMMonitorTests: XCTestCase { super.tearDown() } + // MARK: - Current Session Id + func testWhenSessionIsSampledIn_itReturnsCurrentSessionId() throws { + // Given + var capturedSession: String? + config.dateProvider = RelativeDateProvider(startingFrom: Date(), advancingBySeconds: 1) + config.sessionSampleRate = 100.0 + config.onSessionStart = { session, sampled in + capturedSession = session + } + RUM.enable(with: config, in: core) + let monitor = RUMMonitor.shared(in: core) + + // When + let expectation = XCTestExpectation(description: "currentSessionID callback recieved") + monitor.currentSessionID { sessionId in + // Then + XCTAssertNotNil(sessionId) + XCTAssertEqual(capturedSession, sessionId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.1) + } + + func testWhenSessionIsSampled_itReturnsNil() throws { + // Given + config.dateProvider = RelativeDateProvider(startingFrom: Date(), advancingBySeconds: 1) + config.sessionSampleRate = 0.0 + RUM.enable(with: config, in: core) + let monitor = RUMMonitor.shared(in: core) + + // When + let expectation = XCTestExpectation(description: "currentSessionID callback recieved") + monitor.currentSessionID { sessionId in + // Then + XCTAssertNil(sessionId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.1) + } + + func testWhenSessionIsStopped_itReturnsNil() throws { + // Given + config.dateProvider = RelativeDateProvider(startingFrom: Date(), advancingBySeconds: 1) + config.sessionSampleRate = 100.0 + RUM.enable(with: config, in: core) + let monitor = RUMMonitor.shared(in: core) + + setGlobalAttributes(of: monitor) + monitor.startView(viewController: mockView) + + // When + monitor.stopSession() + let expectation = XCTestExpectation(description: "currentSessionID callback recieved") + monitor.currentSessionID { sessionId in + // Then + XCTAssertNil(sessionId) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.1) + } + // MARK: - Sending RUM events func testStartingViewIdentifiedByViewController() throws { @@ -46,12 +110,12 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() - let firstVisit = session.viewVisits[0] + let firstVisit = try session.views.dropApplicationLaunchView()[0] XCTAssertEqual(firstVisit.viewEvents.last?.view.timeSpent, 1_000_000_000) - let secondVisit = session.viewVisits[1] + let secondVisit = try session.views.dropApplicationLaunchView()[1] XCTAssertEqual(secondVisit.viewEvents.last?.view.action.count, 0) } @@ -69,12 +133,13 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - XCTAssertEqual(session.viewVisits.count, 2) - XCTAssertEqual(session.viewVisits[0].name, "View1") - XCTAssertEqual(session.viewVisits[0].path, "view1-key") - XCTAssertEqual(session.viewVisits[1].name, "View2") - XCTAssertEqual(session.viewVisits[1].path, "view2-key") + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + XCTAssertEqual(views.count, 2) + XCTAssertEqual(views[0].name, "View1") + XCTAssertEqual(views[0].path, "view1-key") + XCTAssertEqual(views[1].name, "View2") + XCTAssertEqual(views[1].path, "view2-key") } func testStartingView_thenLoadingImageResourceWithRequest() throws { @@ -88,22 +153,16 @@ class RUMMonitorTests: XCTestCase { monitor.stopResource(resourceKey: "/resource/1", response: .mockWith(statusCode: 200, mimeType: "image/png")) let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() - .filterApplicationLaunchView() - .filterTelemetry() - verifyGlobalAttributes(in: rumEventMatchers) - try rumEventMatchers[0].model(ofType: RUMViewEvent.self) { rumModel in - XCTAssertEqual(rumModel.view.action.count, 0) - XCTAssertEqual(rumModel.view.resource.count, 0) - } - try rumEventMatchers[1].model(ofType: RUMResourceEvent.self) { rumModel in - XCTAssertEqual(rumModel.resource.type, .image) - XCTAssertEqual(rumModel.resource.statusCode, 200) - } - try rumEventMatchers[2].model(ofType: RUMViewEvent.self) { rumModel in - XCTAssertEqual(rumModel.view.action.count, 0) - XCTAssertEqual(rumModel.view.resource.count, 1) - } + + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let viewEvent = try XCTUnwrap(views[0].viewEvents.last) + let resourceEvent = try XCTUnwrap(views[0].resourceEvents.last) + XCTAssertEqual(viewEvent.view.resource.count, 1) + XCTAssertEqual(resourceEvent.resource.type, .image) + XCTAssertEqual(resourceEvent.resource.statusCode, 200) + XCTAssertEqual(resourceEvent.view.id, viewEvent.view.id) } func testStartingView_thenLoadingNativeResourceWithRequestWithMetrics() throws { @@ -135,8 +194,9 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] XCTAssertEqual(resourceEvent.resource.type, .native, "POST Resources should always have the `.native` kind") XCTAssertEqual(resourceEvent.resource.statusCode, 200) XCTAssertEqual(resourceEvent.resource.duration, 4_000_000_000) @@ -158,8 +218,9 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] XCTAssertEqual(resourceEvent.resource.url, url.absoluteString) XCTAssertEqual(resourceEvent.resource.statusCode, 200) XCTAssertNil(resourceEvent.resource.provider?.type) @@ -178,8 +239,9 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] XCTAssertEqual(resourceEvent.resource.url, "/some/url/string") XCTAssertEqual(resourceEvent.resource.statusCode, 333) XCTAssertEqual(resourceEvent.resource.type, .beacon) @@ -204,9 +266,10 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] - XCTAssertEqual(resourceEvent.resource.provider?.type, RUMResourceEvent.Resource.Provider.ProviderType.firstParty) + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] + XCTAssertEqual(resourceEvent.resource.provider?.type, .firstParty) } func testLoadingResourceWithURLString_thenMarksFirstPartyURLs() throws { @@ -225,9 +288,10 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] - XCTAssertEqual(resourceEvent.resource.provider?.type, RUMResourceEvent.Resource.Provider.ProviderType.firstParty) + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] + XCTAssertEqual(resourceEvent.resource.provider?.type, .firstParty) } func testLoadingResourceWithURLString_thenLoadingResourceWithGraphQLAttributes() throws { @@ -248,8 +312,9 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let session = try XCTUnwrap(try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first) - let resourceEvent = session.viewVisits[0].resourceEvents[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + let resourceEvent = views[0].resourceEvents[0] XCTAssertEqual(resourceEvent.resource.graphql?.operationName, "GetCountry") XCTAssertEqual(resourceEvent.resource.graphql?.operationType.rawValue, "query") XCTAssertEqual(resourceEvent.resource.graphql?.payload, "{country(code:$code){name}}") @@ -372,10 +437,12 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let rumSession = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first.unwrapOrThrow() - XCTAssertEqual(rumSession.viewVisits.count, 1, "Session should track one view") + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + + XCTAssertEqual(views.count, 1, "Session should track one view") - let firstView = rumSession.viewVisits[0] + let firstView = views[0] XCTAssertEqual(firstView.viewEvents.last?.view.action.count, 1, "View must track 1 action") XCTAssertEqual(firstView.viewEvents.last?.view.resource.count, 0, "View must track no resources") XCTAssertEqual(firstView.viewEvents.last?.view.error.count, 3, "View must track 3 errors") @@ -484,11 +551,12 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() verifyGlobalAttributes(in: rumEventMatchers) - let rumSession = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).first.unwrapOrThrow() - XCTAssertEqual(rumSession.viewVisits.count, 2, "Session should track two views") + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() + XCTAssertEqual(views.count, 2, "Session should track two views") - let firstView = rumSession.viewVisits[0] - let secondView = rumSession.viewVisits[1] + let firstView = views[0] + let secondView = views[1] XCTAssertEqual(firstView.viewEvents.last?.view.url, "FirstViewController") XCTAssertEqual(firstView.viewEvents.last?.view.name, "FirstViewController") XCTAssertEqual(firstView.viewEvents.last?.view.resource.count, 1, "First view must track 1 resource") @@ -846,10 +914,11 @@ class RUMMonitorTests: XCTestCase { let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers)[0] - let viewEvents = session.viewVisits[0].viewEvents - let actionEvents = session.viewVisits[0].actionEvents - let resourceEvents = session.viewVisits[0].resourceEvents - let errorEvents = session.viewVisits[0].errorEvents + let views = try session.views.dropApplicationLaunchView() + let viewEvents = views[0].viewEvents + let actionEvents = views[0].actionEvents + let resourceEvents = views[0].resourceEvents + let errorEvents = views[0].errorEvents XCTAssertGreaterThan(viewEvents.count, 0) XCTAssertGreaterThan(actionEvents.count, 0) @@ -966,12 +1035,11 @@ class RUMMonitorTests: XCTestCase { // Then let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() - let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers)[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() - XCTAssertNotNil(session.applicationLaunchView) - XCTAssertEqual(session.viewVisits.count, 1, "It should track 1 views") + XCTAssertEqual(session.views.count, 2, "It should track 2 views") - let appLaunchView = try XCTUnwrap(session.applicationLaunchView) + let appLaunchView = session.views[0] let launchDateInMilliseconds = launchDate.timeIntervalSince1970.toInt64Milliseconds let sdkInitDateInMilliseconds = sdkInitDate.timeIntervalSince1970.toInt64Milliseconds @@ -987,7 +1055,7 @@ class RUMMonitorTests: XCTestCase { XCTAssertEqual(appLaunchView.resourceEvents.count, 1, "'ApplicationLaunch' should track 1 resource") XCTAssertEqual(appLaunchView.resourceEvents[0].resource.url, "https://foo.com/R1", "'ApplicationLaunch' should track 'R1' resource") - let userView = session.viewVisits[0] + let userView = session.views[1] XCTAssertEqual(userView.name, "FirstView", "It should track user view") XCTAssertEqual(userView.actionEvents.count, 1, "User view should track 1 action") XCTAssertEqual(userView.actionEvents[0].action.target?.name, "A2", "User view should track 'A2' action") @@ -1043,22 +1111,20 @@ class RUMMonitorTests: XCTestCase { monitor._internal?.addLongTask(at: Date(), duration: 1.0) let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() - let sessions = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers) - - XCTAssertEqual(sessions.count, 1, "All events should belong to a single RUM Session") - let session = sessions[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() - session.viewVisits[0].viewEvents.forEach { viewEvent in + views[0].viewEvents.forEach { viewEvent in XCTAssertEqual(viewEvent.view.url, "ModifiedViewURL") XCTAssertEqual(viewEvent.view.name, "ModifiedViewName") } - XCTAssertEqual(session.viewVisits[0].resourceEvents.count, 1) - XCTAssertEqual(session.viewVisits[0].resourceEvents[0].resource.url, "https://foo.com?q=modified-resource-url") - XCTAssertEqual(session.viewVisits[0].actionEvents.count, 1) - XCTAssertEqual(session.viewVisits[0].actionEvents[0].action.target?.name, "Modified tap action name") - XCTAssertEqual(session.viewVisits[0].errorEvents.count, 1) - XCTAssertEqual(session.viewVisits[0].errorEvents[0].error.message, "Modified error message") - XCTAssertEqual(session.viewVisits[0].longTaskEvents[0].view.name, "ModifiedLongTaskViewName") + XCTAssertEqual(views[0].resourceEvents.count, 1) + XCTAssertEqual(views[0].resourceEvents[0].resource.url, "https://foo.com?q=modified-resource-url") + XCTAssertEqual(views[0].actionEvents.count, 1) + XCTAssertEqual(views[0].actionEvents[0].action.target?.name, "Modified tap action name") + XCTAssertEqual(views[0].errorEvents.count, 1) + XCTAssertEqual(views[0].errorEvents[0].error.message, "Modified error message") + XCTAssertEqual(views[0].longTaskEvents[0].view.name, "ModifiedLongTaskViewName") } func testDroppingEventsBeforeTheyGetSent() throws { @@ -1078,20 +1144,18 @@ class RUMMonitorTests: XCTestCase { monitor._internal?.addLongTask(at: Date(), duration: 1.0) let rumEventMatchers = try core.waitAndReturnRUMEventMatchers() - let sessions = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers) - - XCTAssertEqual(sessions.count, 1, "All events should belong to a single RUM Session") - let session = sessions[0] + let session = try RUMSessionMatcher.groupMatchersBySessions(rumEventMatchers).takeSingle() + let views = try session.views.dropApplicationLaunchView() - XCTAssertNotEqual(session.viewVisits[0].viewEvents.count, 0) - let lastEvent = session.viewVisits[0].viewEvents.last! + XCTAssertNotEqual(views[0].viewEvents.count, 0) + let lastEvent = views[0].viewEvents.last! XCTAssertEqual(lastEvent.view.resource.count, 0, "resource.count should reflect all resource events being dropped.") XCTAssertEqual(lastEvent.view.action.count, 0, "action.count should reflect all action events being dropped.") XCTAssertEqual(lastEvent.view.error.count, 0, "error.count should reflect all error events being dropped.") - XCTAssertEqual(session.viewVisits[0].resourceEvents.count, 0) - XCTAssertEqual(session.viewVisits[0].actionEvents.count, 0) - XCTAssertEqual(session.viewVisits[0].errorEvents.count, 0) - XCTAssertEqual(session.viewVisits[0].longTaskEvents.count, 0) + XCTAssertEqual(views[0].resourceEvents.count, 0) + XCTAssertEqual(views[0].actionEvents.count, 0) + XCTAssertEqual(views[0].errorEvents.count, 0) + XCTAssertEqual(views[0].longTaskEvents.count, 0) } // MARK: - Integration with Crash Reporting diff --git a/DatadogCore/Tests/Datadog/Tracing/DatadogTraceFeatureTests.swift b/DatadogCore/Tests/Datadog/Tracing/DatadogTraceFeatureTests.swift index b27e5745e2..2c163c9c5f 100644 --- a/DatadogCore/Tests/Datadog/Tracing/DatadogTraceFeatureTests.swift +++ b/DatadogCore/Tests/Datadog/Tracing/DatadogTraceFeatureTests.swift @@ -67,6 +67,7 @@ class DatadogTraceFeatureTests: XCTestCase { ) ), applicationVersion: randomApplicationVersion, + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: randomBackgroundTasksEnabled ) defer { core.flushAndTearDown() } @@ -130,6 +131,7 @@ class DatadogTraceFeatureTests: XCTestCase { encryption: nil, contextProvider: .mockAny(), applicationVersion: .mockAny(), + maxBatchesPerUpload: .mockRandom(min: 1, max: 100), backgroundTasksEnabled: .mockAny() ) defer { core.flushAndTearDown() } diff --git a/DatadogCore/Tests/DatadogObjc/DDConfigurationTests.swift b/DatadogCore/Tests/DatadogObjc/DDConfigurationTests.swift index a13bb793e1..0696ba25de 100644 --- a/DatadogCore/Tests/DatadogObjc/DDConfigurationTests.swift +++ b/DatadogCore/Tests/DatadogObjc/DDConfigurationTests.swift @@ -62,6 +62,12 @@ class DDConfigurationTests: XCTestCase { objcConfig.uploadFrequency = .rare XCTAssertEqual(objcConfig.sdkConfiguration.uploadFrequency, .rare) + objcConfig.batchProcessingLevel = .low + XCTAssertEqual(objcConfig.sdkConfiguration.batchProcessingLevel, .low) + + objcConfig.batchProcessingLevel = .high + XCTAssertEqual(objcConfig.sdkConfiguration.batchProcessingLevel, .high) + objcConfig.proxyConfiguration = [kCFNetworkProxiesHTTPEnable: true, kCFNetworkProxiesHTTPPort: 123, kCFNetworkProxiesHTTPProxy: "www.example.com", kCFProxyUsernameKey: "proxyuser", kCFProxyPasswordKey: "proxypass" ] XCTAssertEqual(objcConfig.sdkConfiguration.proxyConfiguration?[kCFNetworkProxiesHTTPEnable] as? Bool, true) XCTAssertEqual(objcConfig.sdkConfiguration.proxyConfiguration?[kCFNetworkProxiesHTTPPort] as? Int, 123) diff --git a/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift b/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift index 735b50ab3d..630db16e87 100644 --- a/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift +++ b/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift @@ -68,7 +68,7 @@ class DDSessionReplayTests: XCTestCase { // Then let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) - let requestBuilder = try XCTUnwrap(sr.requestBuilder as? DatadogSessionReplay.RequestBuilder) + let requestBuilder = try XCTUnwrap(sr.requestBuilder as? DatadogSessionReplay.SegmentRequestBuilder) XCTAssertEqual(sr.recordingCoordinator.sampler.samplingRate, 42) XCTAssertEqual(sr.recordingCoordinator.privacy, .mask) XCTAssertNil(requestBuilder.customUploadURL) diff --git a/DatadogCore/Tests/Matchers/JSONDataMatcher.swift b/DatadogCore/Tests/Matchers/JSONDataMatcher.swift index 73a87905e3..a7576cd626 100644 --- a/DatadogCore/Tests/Matchers/JSONDataMatcher.swift +++ b/DatadogCore/Tests/Matchers/JSONDataMatcher.swift @@ -91,6 +91,8 @@ internal class JSONDataMatcher { let description: String } + /// Returns value at given key-path by casting it to expected type. + /// Throws an error if value at given key-path does not exist. func value(forKeyPath keyPath: String) throws -> T { let dictionary = json as NSDictionary guard let anyValue = dictionary.value(forKeyPath: keyPath) else { @@ -105,4 +107,19 @@ internal class JSONDataMatcher { } return tValue } + + /// Returns value at given key-path by casting it to expected type. + /// Returns `nil` if no value at given key-path exist. + func valueOrNil(forKeyPath keyPath: String) throws -> T? { + let dictionary = json as NSDictionary + guard let anyValue = dictionary.value(forKeyPath: keyPath) else { + return nil + } + guard let tValue = anyValue as? T else { + throw Exception( + description: "Cannot cast value for key path `\(keyPath)` to type `\(T.self)`: \(String(describing: anyValue))" + ) + } + return tValue + } } diff --git a/DatadogCore/Tests/Matchers/RUMEventMatcher.swift b/DatadogCore/Tests/Matchers/RUMEventMatcher.swift index d11e382132..a1abf27cc4 100644 --- a/DatadogCore/Tests/Matchers/RUMEventMatcher.swift +++ b/DatadogCore/Tests/Matchers/RUMEventMatcher.swift @@ -82,7 +82,7 @@ internal class RUMEventMatcher { func eventType() throws -> String { try jsonMatcher.value(forKeyPath: "type") } - func sessionHasReplay() throws -> Bool? { try jsonMatcher.value(forKeyPath: "session.has_replay") } + func sessionHasReplay() throws -> Bool? { try jsonMatcher.valueOrNil(forKeyPath: "session.has_replay") } func userID() throws -> String { try jsonMatcher.value(forKeyPath: "usr.id") } func userName() throws -> String { try jsonMatcher.value(forKeyPath: "usr.name") } diff --git a/DatadogCore/Tests/Matchers/RUMSessionMatcher.swift b/DatadogCore/Tests/Matchers/RUMSessionMatcher.swift index 188a070979..d832985aaf 100644 --- a/DatadogCore/Tests/Matchers/RUMSessionMatcher.swift +++ b/DatadogCore/Tests/Matchers/RUMSessionMatcher.swift @@ -52,7 +52,7 @@ internal class RUMSessionMatcher { /// Single RUM View visit tracked in this RUM Session. /// Groups all the `RUMEvents` send during this visit. - class ViewVisit { + class View { /// The identifier of all `RUM Views` tracked during this visit. let viewID: String @@ -93,11 +93,9 @@ internal class RUMSessionMatcher { /// The ID of this session in RUM. let sessionID: String - let applicationLaunchView: ViewVisit? - - /// An array of view visits tracked during this RUM Session. + /// An array of view visits tracked during this RUM session. /// Each `ViewVisit` is determined by unique `view.id` and groups all RUM events linked to that `view.id`.' - let viewVisits: [ViewVisit] + let views: [View] /// All RUM events in this session. let allEvents: [RUMEventMatcher] @@ -154,9 +152,9 @@ internal class RUMSessionMatcher { // Group RUMView events into ViewVisits: let uniqueViewIDs = Set(viewEvents.map { $0.view.id }) - let visits = uniqueViewIDs.map { viewID in ViewVisit(viewID: viewID) } + let visits = uniqueViewIDs.map { viewID in View(viewID: viewID) } - var visitsByViewID: [String: ViewVisit] = [:] + var visitsByViewID: [String: View] = [:] visits.forEach { visit in visitsByViewID[visit.viewID] = visit } // Group RUM Events and their matchers by View Visits: @@ -226,7 +224,7 @@ internal class RUMSessionMatcher { } // Sort visits by time - var visitsEventOrderedByTime = visits.sorted { firstVisit, secondVisit in + let visitsEventOrderedByTime = visits.sorted { firstVisit, secondVisit in let firstVisitTime = firstVisit.viewEvents[0].date let secondVisitTime = secondVisit.viewEvents[0].date return firstVisitTime < secondVisitTime @@ -258,23 +256,14 @@ internal class RUMSessionMatcher { } } - if let applicationLaunchIndex = visitsEventOrderedByTime.firstIndex( - where: { $0.name == "ApplicationLaunch" } - ) { - self.applicationLaunchView = visitsEventOrderedByTime[applicationLaunchIndex] - visitsEventOrderedByTime.remove(at: applicationLaunchIndex) - } else { - self.applicationLaunchView = nil - } - - self.viewVisits = visitsEventOrderedByTime + self.views = visitsEventOrderedByTime } /// Checks if this session contains a view with a specific ID. /// - Parameter viewID: The ID of the view to check. /// - Returns: `true` if a view with the given `viewID` is present in this session; otherwise, `false`. func containsView(with viewID: String) -> Bool { - let allIDs = Set(viewVisits.map { $0.viewID }) + let allIDs = Set(views.map { $0.viewID }) return allIDs.contains(viewID) } } @@ -446,18 +435,104 @@ private func strictValidate(os: RUMOperatingSystem) throws { #endif } +// MARK: - Matching + +extension Array where Element == RUMSessionMatcher { + /// Returns the only session in this array. + /// Throws if there is more than one session or the array has no elements. + func takeSingle() throws -> RUMSessionMatcher { + guard !isEmpty else { + throw RUMSessionConsistencyException(description: "There are no sessions in this array") + } + guard count == 1 else { + throw RUMSessionConsistencyException(description: "Expected to find only one session, but found \(count)") + } + return self[0] + } +} + +extension Array where Element == RUMSessionMatcher.View { + /// Returns list of views by dropping "application launch" view. + /// Throws if "application launch" is not the first view in this array. + /// + /// Use it to explicitly ignore the "application launch" view with running strict check of its existence. + func dropApplicationLaunchView() throws -> [RUMSessionMatcher.View] { + guard let first = first else { + throw RUMSessionConsistencyException(description: "Cannot drop 'application launch' view in empty array") + } + guard first.isApplicationLaunchView() else { + throw RUMSessionConsistencyException(description: "The first view in this array is not 'application launch' view (\(first.name ?? "???")") + } + return Array(dropFirst()) + } +} + +extension RUMSessionMatcher.View { + /// Whether this is "application launch" view. + func isApplicationLaunchView() -> Bool { + return name == "ApplicationLaunch" && path == "com/datadog/application-launch/view" + } + + /// Whether this is "background" view. + func isBackgroundView() -> Bool { + return name == "Background" && path == "com/datadog/background/view" + } +} + +private extension Date { + init(millisecondsSince1970: Int64) { + self.init(timeIntervalSince1970: TimeInterval(millisecondsSince1970) / 1_000) + } +} + +private extension TimeInterval { + init(fromNanoseconds nanoseconds: Int64) { + self = TimeInterval(nanoseconds) / 1_000_000_000 + } +} + +extension RUMSessionMatcher { + /// Asserts that all events in this session have certain `sessionPrecondition` set. + /// Throws if there are no views in this session. + func has(sessionPrecondition: RUMSessionPrecondition) throws -> Bool { + guard !views.isEmpty else { + throw RUMSessionConsistencyException(description: "There are no views in this session") + } + + for view in views { + guard view.viewEvents.allSatisfy({ $0.dd.session?.sessionPrecondition == sessionPrecondition }) else { + return false + } + guard view.actionEvents.allSatisfy({ $0.dd.session?.sessionPrecondition == sessionPrecondition }) else { + return false + } + guard view.resourceEvents.allSatisfy({ $0.dd.session?.sessionPrecondition == sessionPrecondition }) else { + return false + } + guard view.errorEvents.allSatisfy({ $0.dd.session?.sessionPrecondition == sessionPrecondition }) else { + return false + } + guard view.longTaskEvents.allSatisfy({ $0.dd.session?.sessionPrecondition == sessionPrecondition }) else { + return false + } + } + + return true + } +} + // MARK: - Debugging extension RUMSessionMatcher: CustomStringConvertible { var description: String { - var description = "[🎞 RUM session (application.id: \(applicationID), session.id: \(sessionID), number of views: \(viewVisits.count))]" - viewVisits.forEach { view in + var description = "[🎞 RUM session (application.id: \(applicationID), session.id: \(sessionID), number of views: \(views.count))]" + views.forEach { view in description += "\n\(describe(viewVisit: view))" } return description } - private func describe(viewVisit: ViewVisit) -> String { + private func describe(viewVisit: View) -> String { guard let lastViewEvent = viewVisit.viewEvents.last else { return " → [⛔️ Invalid View - it has no view events]" } diff --git a/DatadogCore/Tests/TestsObserver/DatadogTestsObserver.swift b/DatadogCore/Tests/TestsObserver/DatadogTestsObserver.swift index dec7193873..117de6c64b 100644 --- a/DatadogCore/Tests/TestsObserver/DatadogTestsObserver.swift +++ b/DatadogCore/Tests/TestsObserver/DatadogTestsObserver.swift @@ -33,13 +33,13 @@ internal class DatadogTestsObserver: NSObject, XCTestObservation { """ ), .init( - assert: { Swizzling.activeSwizzlingNames.isEmpty }, + assert: { Swizzling.methods.isEmpty }, problem: "No swizzling must be applied.", solution: """ Make sure all applied swizzling are reset by the end of test with `unswizzle()`. - `DatadogTestsObserver` found \(Swizzling.activeSwizzlingNames.count) leaked swizzlings: - \(Swizzling.activeSwizzlingNames.joined(separator: ", ")) + `DatadogTestsObserver` found \(Swizzling.methods.count) leaked swizzlings: + \(Swizzling.description) """ ), .init( @@ -132,34 +132,6 @@ internal class DatadogTestsObserver: NSObject, XCTestObservation { If all above conditions are met, this failure might indicate a memory leak in the implementation. """ - ), - .init( - assert: { URLSessionTaskDelegateSwizzler.isBinded == false }, - problem: "No URLSessionTaskDelegate swizzling must be applied.", - solution: """ - Make sure all the binded delegates are unbinded by the end of test with `URLSessionTaskDelegateSwizzler.unbind(delegate:)`. - """ - ), - .init( - assert: { URLSessionDataDelegateSwizzler.isBinded == false }, - problem: "No URLSessionDataDelegate swizzling must be applied.", - solution: """ - Make sure all the binded delegates are unbinded by the end of test with `URLSessionDataDelegateSwizzler.unbind(delegate:)`. - """ - ), - .init( - assert: { URLSessionTaskSwizzler.isBinded == false }, - problem: "No URLSessionTask swizzling must be applied.", - solution: """ - Make sure all the binded delegates are unbinded by the end of test with `URLSessionTaskSwizzler.unbind()`. - """ - ), - .init( - assert: { URLSessionSwizzler.isBinded == false }, - problem: "No URLSession swizzling must be applied.", - solution: """ - Make sure all the binded delegates are unbinded by the end of test with `URLSessionSwizzler.unbind()`. - """ ) ] diff --git a/DatadogCrashReporting.podspec b/DatadogCrashReporting.podspec index 2177c9f39c..cf7abdf178 100644 --- a/DatadogCrashReporting.podspec +++ b/DatadogCrashReporting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogCrashReporting" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Crash Reporting SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogCrashReporting/Sources/PLCrashReporterIntegration/CrashReport.swift b/DatadogCrashReporting/Sources/PLCrashReporterIntegration/CrashReport.swift index ed3ebbc4bc..846252c211 100644 --- a/DatadogCrashReporting/Sources/PLCrashReporterIntegration/CrashReport.swift +++ b/DatadogCrashReporting/Sources/PLCrashReporterIntegration/CrashReport.swift @@ -26,7 +26,7 @@ internal struct CrashReport { var threads: [ThreadInfo] /// Information about binary images loaded by the process. var binaryImages: [BinaryImageInfo] - /// Custom user data injected before the crash occured. + /// Custom user data injected before the crash occurred. var contextData: Data? /// Additional flag (for telemetry) meaning if any of the stack traces was truncated due to minification. var wasTruncated: Bool diff --git a/DatadogInternal.podspec b/DatadogInternal.podspec index 21d87e977a..ba0827be18 100644 --- a/DatadogInternal.podspec +++ b/DatadogInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogInternal" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog Internal Package. This module is not for public use." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogInternal/Sources/Attributes/Attributes.swift b/DatadogInternal/Sources/Attributes/Attributes.swift index 139860c609..aad368b5b5 100644 --- a/DatadogInternal/Sources/Attributes/Attributes.swift +++ b/DatadogInternal/Sources/Attributes/Attributes.swift @@ -87,6 +87,10 @@ public struct CrossPlatformAttributes { /// It does not replace any default native properties as iOS does not have the concept of 'flavors' or variants. public static let variant: String = "_dd.variant" + /// A custom unique id that identifies this build of the application, used from symbolication and deobfuscation + /// Id does not replace any default native properties and is sent in addition to version and build number + public static let buildId: String = "_dd.build_id" + /// Event timestamp passed from CP SDK. Used for all RUM events issued by cross platform SDK. /// It should replace event time obtained from `DateProvider` to ensure that events are not skewed due to time difference in native and cross-platform SDKs. /// Expects `Int64` value (milliseconds). diff --git a/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift b/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift index 596c9406c7..bb46135609 100644 --- a/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift +++ b/DatadogInternal/Sources/Concurrency/ReadWriteLock.swift @@ -39,20 +39,16 @@ public final class ReadWriteLock { defer { pthread_rwlock_unlock(&rwlock) } return value } - set { - pthread_rwlock_wrlock(&rwlock) - value = newValue - pthread_rwlock_unlock(&rwlock) - } + set { mutate { $0 = newValue } } } /// Provides a non-escaping closure for mutation. /// The lock will be acquired once for writing before invoking the closure. /// /// - Parameter closure: The closure with the mutable value. - public func mutate(_ closure: (inout Value) -> Void) { + public func mutate(_ closure: (inout Value) throws -> Void) rethrows { pthread_rwlock_wrlock(&rwlock) - closure(&value) - pthread_rwlock_unlock(&rwlock) + defer { pthread_rwlock_unlock(&rwlock) } + try closure(&value) } } diff --git a/DatadogInternal/Sources/Context/AppState.swift b/DatadogInternal/Sources/Context/AppState.swift index 7452df6416..f3a57a0236 100644 --- a/DatadogInternal/Sources/Context/AppState.swift +++ b/DatadogInternal/Sources/Context/AppState.swift @@ -45,7 +45,7 @@ public struct AppStateHistory: Codable, Equatable, PassthroughAnyCodable { public private(set) var initialSnapshot: Snapshot public private(set) var snapshots: [Snapshot] - /// Date of last the update to `AppStateHistory`. + /// Date of the last update to `AppStateHistory`. public private(set) var recentDate: Date /// The most recent app state `Snapshot`. diff --git a/DatadogInternal/Sources/Context/DatadogContext.swift b/DatadogInternal/Sources/Context/DatadogContext.swift index 16be7a8479..316f0b4959 100644 --- a/DatadogInternal/Sources/Context/DatadogContext.swift +++ b/DatadogInternal/Sources/Context/DatadogContext.swift @@ -29,6 +29,9 @@ public struct DatadogContext { /// The build number of the application that data is generated from. public let buildNumber: String + /// The id of the build, specifically for cross platform frameworks + public let buildId: String? + /// The variant of the build, equivelent to Android's "Flavor". Only used by cross platform SDKs public let variant: String? @@ -111,6 +114,7 @@ public struct DatadogContext { env: String, version: String, buildNumber: String, + buildId: String?, variant: String?, source: String, sdkVersion: String, @@ -136,6 +140,7 @@ public struct DatadogContext { self.env = env self.version = version self.buildNumber = buildNumber + self.buildId = buildId self.variant = variant self.source = source self.sdkVersion = sdkVersion diff --git a/DatadogInternal/Sources/DatadogFeature.swift b/DatadogInternal/Sources/DatadogFeature.swift index fb6b262ecc..6a726d0532 100644 --- a/DatadogInternal/Sources/DatadogFeature.swift +++ b/DatadogInternal/Sources/DatadogFeature.swift @@ -6,28 +6,6 @@ import Foundation -public struct DatadogFeatureConfiguration { - /// The Feature name. - public let name: String - - /// The URL request builder for uploading data in this Feature. - /// - /// This builder currently use the v1 context, but will be soon migrated to v2 - public let requestBuilder: FeatureRequestBuilder - - /// The message bus receiver. - /// - /// The `FeatureMessageReceiver` defines an interface for Feature to receive any message - /// from a bus that is shared between Features registered in a core. - public let messageReceiver: FeatureMessageReceiver - - public init(name: String, requestBuilder: FeatureRequestBuilder, messageReceiver: FeatureMessageReceiver) { - self.name = name - self.requestBuilder = requestBuilder - self.messageReceiver = messageReceiver - } -} - /// A Datadog Feature that can interact with the core through the message-bus. public protocol DatadogFeature { /// The feature name. diff --git a/DatadogInternal/Sources/NetworkInstrumentation/NetworkInstrumentationFeature.swift b/DatadogInternal/Sources/NetworkInstrumentation/NetworkInstrumentationFeature.swift index c5ca04903b..8dccc27d0a 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/NetworkInstrumentationFeature.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/NetworkInstrumentationFeature.swift @@ -38,6 +38,9 @@ internal final class NetworkInstrumentationFeature: DatadogFeature { @ReadWriteLock internal var handlers: [DatadogURLSessionHandler] = [] + @ReadWriteLock + private var swizzlers: [ObjectIdentifier: NetworkInstrumentationSwizzler] = [:] + /// Maps `URLSessionTask` to its `TaskInterception` object. /// /// The interceptions **must** be accessed using the `queue`. @@ -49,80 +52,76 @@ internal final class NetworkInstrumentationFeature: DatadogFeature { /// - Parameter configuration: The configuration to use for swizzling. /// Note: We are only concerned with type of the delegate here but to provide compile time safety, we /// use the instance of the delegate to get the type. - internal func bindIfNeeded(configuration: URLSessionInstrumentation.Configuration) throws { + internal func bind(configuration: URLSessionInstrumentation.Configuration) throws { let configuredFirstPartyHosts = FirstPartyHosts(firstPartyHosts: configuration.firstPartyHostsTracing) ?? .init() - try URLSessionTaskDelegateSwizzler.bindIfNeeded( + let identifier = ObjectIdentifier(configuration.delegateClass) + + if let swizzler = swizzlers[identifier] { + DD.logger.warn( + """ + The delegate class \(configuration.delegateClass) is already instrumented. + The previous instrumentation will be disabled in favor of the new one. + """ + ) + + swizzler.unswizzle() + } + + let swizzler = NetworkInstrumentationSwizzler() + swizzlers[identifier] = swizzler + + try swizzler.swizzle( + interceptResume: { [weak self] task in + // intercept task if delegate match + guard let self = self, task.dd.delegate?.isKind(of: configuration.delegateClass) == true else { + return + } + + if let currentRequest = task.currentRequest { + let request = self.intercept(request: currentRequest, additionalFirstPartyHosts: configuredFirstPartyHosts) + task.dd.override(currentRequest: request) + } + + self.intercept(task: task, additionalFirstPartyHosts: configuredFirstPartyHosts) + } + ) + + try swizzler.swizzle( delegateClass: configuration.delegateClass, interceptDidFinishCollecting: { [weak self] session, task, metrics in - self?.queue.async { [weak self, weak session] in - self?._task(task, didFinishCollecting: metrics) - session?.delegate?.interceptor?.task(task, didFinishCollecting: metrics) + self?.task(task, didFinishCollecting: metrics) - // iOS 16 and above, didCompleteWithError is not called hence we use task state to detect task completion + if #available(iOS 15, tvOS 15, *) { + // iOS 15 and above, didCompleteWithError is not called hence we use task state to detect task completion // while prior to iOS 15, task state doesn't change to completed hence we use didCompleteWithError to detect task completion - if #available(iOS 15, tvOS 15, *) { - self?._task(task, didCompleteWithError: task.error) - session?.delegate?.interceptor?.task(task, didCompleteWithError: task.error) - } - } - }, interceptDidCompleteWithError: { [weak self] session, task, error in - self?.queue.async { [weak self, weak session] in - // prior to iOS 15, task state doesn't change to completed - // hence we use didCompleteWithError to detect task completion - self?._task(task, didCompleteWithError: task.error) - session?.delegate?.interceptor?.task(task, didCompleteWithError: task.error) + self?.task(task, didCompleteWithError: task.error) } + }, + interceptDidCompleteWithError: { [weak self] session, task, error in + self?.task(task, didCompleteWithError: error) } ) - try URLSessionDataDelegateSwizzler.bindIfNeeded(delegateClass: configuration.delegateClass, interceptDidReceive: { [weak self] session, task, data in - // sync update to task prevents a race condition where the currentRequest could already be sent to the transport - self?.queue.sync { [weak self, weak session] in - self?._task(task, didReceive: data) - session?.delegate?.interceptor?.task(task, didReceive: data) + try swizzler.swizzle( + delegateClass: configuration.delegateClass, + interceptDidReceive: { [weak self] session, task, data in + self?.task(task, didReceive: data) } - }) - - if #available(iOS 13, tvOS 13, *) { - try URLSessionTaskSwizzler.bindIfNeeded(interceptResume: { [weak self] task in - self?.queue.sync { [weak self] in - let additionalFirstPartyHosts = configuredFirstPartyHosts + task.firstPartyHosts - self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) - } - }) - } else { - try URLSessionSwizzler.bindIfNeeded(interceptURLRequest: { request in - return self.intercept(request: request, additionalFirstPartyHosts: configuredFirstPartyHosts) - }, interceptTask: { [weak self] task in - self?.queue.async { [weak self] in - let additionalFirstPartyHosts = configuredFirstPartyHosts + task.firstPartyHosts - self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) - } - }) - } - } + ) - internal func unbindAll() { - URLSessionTaskDelegateSwizzler.unbindAll() - URLSessionDataDelegateSwizzler.unbindAll() - URLSessionTaskSwizzler.unbind() - URLSessionSwizzler.unbind() + try swizzler.swizzle( + interceptCompletionHandler: { [weak self] task, _, error in + self?.task(task, didCompleteWithError: error) + } + ) } /// Unswizzles `URLSessionTaskDelegate`, `URLSessionDataDelegate`, `URLSessionTask` and `URLSession` methods /// - Parameter delegateClass: The delegate class to unswizzle. internal func unbind(delegateClass: URLSessionDataDelegate.Type) { - URLSessionTaskDelegateSwizzler.unbind(delegateClass: delegateClass) - URLSessionDataDelegateSwizzler.unbind(delegateClass: delegateClass) - - guard URLSessionTaskDelegateSwizzler.didFinishCollectingMap.isEmpty, - URLSessionDataDelegateSwizzler.didReceiveMap.isEmpty else { - return - } - - URLSessionTaskSwizzler.unbind() - URLSessionSwizzler.unbind() + let identifier = ObjectIdentifier(delegateClass) + swizzlers.removeValue(forKey: identifier) } } @@ -152,47 +151,32 @@ extension NetworkInstrumentationFeature { /// - task: The created task. /// - additionalFirstPartyHosts: Extra hosts to consider in the interception. func intercept(task: URLSessionTask, additionalFirstPartyHosts: FirstPartyHosts?) { - // sync update to task prevents a race condition where the currentRequest could already be sent to the transport - queue.sync { [weak self] in - self?._intercept(task: task, additionalFirstPartyHosts: additionalFirstPartyHosts) - } - } - - private func _intercept(task: URLSessionTask, additionalFirstPartyHosts: FirstPartyHosts?) { - guard let originalRequest = task.originalRequest else { - return - } + queue.async { [weak self] in + guard let self = self, let request = task.currentRequest else { + return + } - var interceptedRequest: URLRequest - /// task.setValue is not available on iOS 12, hence for iOS 12 we modify the request by swizzling URLSession methods - if #available(iOS 13, tvOS 13, *) { - let request = self.intercept(request: originalRequest, additionalFirstPartyHosts: additionalFirstPartyHosts) - interceptedRequest = request - task.setValue(interceptedRequest, forKey: "currentRequest") - } else { - interceptedRequest = originalRequest - } + let firstPartyHosts = self.firstPartyHosts(with: additionalFirstPartyHosts) - let firstPartyHosts = self.firstPartyHosts(with: additionalFirstPartyHosts) + let interception = self.interceptions[task] ?? + URLSessionTaskInterception( + request: request, + isFirstParty: firstPartyHosts.isFirstParty(url: request.url) + ) - let interception = self.interceptions[task] ?? - URLSessionTaskInterception( - request: interceptedRequest, - isFirstParty: firstPartyHosts.isFirstParty(url: interceptedRequest.url) - ) + interception.register(request: request) - interception.register(request: interceptedRequest) + if let trace = self.extractTrace(firstPartyHosts: firstPartyHosts, request: request) { + interception.register(traceID: trace.traceID, spanID: trace.spanID, parentSpanID: trace.parentSpanID) + } - if let trace = self.extractTrace(firstPartyHosts: firstPartyHosts, request: interceptedRequest) { - interception.register(traceID: trace.traceID, spanID: trace.spanID, parentSpanID: trace.parentSpanID) - } + if let origin = request.value(forHTTPHeaderField: TracingHTTPHeaders.originField) { + interception.register(origin: origin) + } - if let origin = interceptedRequest.value(forHTTPHeaderField: TracingHTTPHeaders.originField) { - interception.register(origin: origin) + self.interceptions[task] = interception + self.handlers.forEach { $0.interceptionDidStart(interception: interception) } } - - self.interceptions[task] = interception - self.handlers.forEach { $0.interceptionDidStart(interception: interception) } } /// Tells the interceptors that metrics were collected for the given task. @@ -202,21 +186,17 @@ extension NetworkInstrumentationFeature { /// - metrics: The collected metrics. func task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { queue.async { [weak self] in - self?._task(task, didFinishCollecting: metrics) - } - } - - private func _task(_ task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - guard let interception = self.interceptions[task] else { - return - } + guard let self = self, let interception = self.interceptions[task] else { + return + } - interception.register( - metrics: ResourceMetrics(taskMetrics: metrics) - ) + interception.register( + metrics: ResourceMetrics(taskMetrics: metrics) + ) - if interception.isDone { - self.finish(task: task, interception: interception) + if interception.isDone { + self.finish(task: task, interception: interception) + } } } @@ -227,17 +207,10 @@ extension NetworkInstrumentationFeature { /// - data: A data object containing the transferred data. func task(_ task: URLSessionTask, didReceive data: Data) { queue.async { [weak self] in - self?._task(task, didReceive: data) + self?.interceptions[task]?.register(nextData: data) } } - private func _task(_ task: URLSessionTask, didReceive data: Data) { - guard let interception = self.interceptions[task] else { - return - } - interception.register(nextData: data) - } - /// Tells the interceptors that the task did complete. /// /// - Parameters: @@ -245,22 +218,18 @@ extension NetworkInstrumentationFeature { /// - error: If an error occurred, an error object indicating how the transfer failed, otherwise NULL. func task(_ task: URLSessionTask, didCompleteWithError error: Error?) { queue.async { [weak self] in - self?._task(task, didCompleteWithError: error) - } - } - - private func _task(_ task: URLSessionTask, didCompleteWithError error: Error?) { - guard let interception = self.interceptions[task] else { - return - } + guard let self = self, let interception = self.interceptions[task] else { + return + } - interception.register( - response: task.response, - error: error - ) + interception.register( + response: task.response, + error: error + ) - if interception.isDone { - self.finish(task: task, interception: interception) + if interception.isDone { + self.finish(task: task, interception: interception) + } } } diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/DatadogURLSessionDelegate.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/DatadogURLSessionDelegate.swift index a2732af537..1462812d0b 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/DatadogURLSessionDelegate.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/DatadogURLSessionDelegate.swift @@ -14,6 +14,13 @@ public typealias DDURLSessionDelegate = DatadogURLSessionDelegate @objc @available(*, deprecated, message: "Use `URLSessionInstrumentation.enable(with:)` instead.") public protocol __URLSessionDelegateProviding: URLSessionDelegate { + /// Datadog delegate object. + /// + /// The class implementing `DDURLSessionDelegateProviding` must ensure that following method calls are forwarded to `ddURLSessionDelegate`: + /// - `func urlSession(_:task:didFinishCollecting:)` + /// - `func urlSession(_:task:didCompleteWithError:)` + /// - `func urlSession(_:dataTask:didReceive:)` + var ddURLSessionDelegate: DatadogURLSessionDelegate { get } } /// The `URLSession` delegate object which enables network requests instrumentation. **It must be @@ -28,7 +35,7 @@ open class DatadogURLSessionDelegate: NSObject, URLSessionDataDelegate { return URLSessionInterceptor.shared(in: core) } - /* private */ public let firstPartyHosts: FirstPartyHosts + let swizzler = NetworkInstrumentationSwizzler() /// The instance of the SDK core notified by this delegate. /// @@ -39,17 +46,8 @@ open class DatadogURLSessionDelegate: NSObject, URLSessionDataDelegate { @objc override public init() { core = nil - firstPartyHosts = .init() - - URLSessionInstrumentation.enable( - with: .init( - delegateClass: Self.self, - firstPartyHostsTracing: .traceWithHeaders(hostsWithHeaders: firstPartyHosts.hostsWithTracingHeaderTypes) - ), - in: core ?? CoreRegistry.default - ) - super.init() + swizzle(firstPartyHosts: .init()) } /// Automatically tracked hosts can be customized per instance with this initializer. @@ -94,20 +92,17 @@ open class DatadogURLSessionDelegate: NSObject, URLSessionDataDelegate { additionalFirstPartyHostsWithHeaderTypes: [String: Set] = [:] ) { self.core = core - self.firstPartyHosts = FirstPartyHosts(additionalFirstPartyHostsWithHeaderTypes) - - URLSessionInstrumentation.enable( - with: .init( - delegateClass: Self.self, - firstPartyHostsTracing: .traceWithHeaders(hostsWithHeaders: firstPartyHosts.hostsWithTracingHeaderTypes) - ), - in: core ?? CoreRegistry.default - ) super.init() + swizzle(firstPartyHosts: FirstPartyHosts(additionalFirstPartyHostsWithHeaderTypes)) } open func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { interceptor?.task(task, didFinishCollecting: metrics) + if #available(iOS 15, tvOS 15, *) { + // iOS 15 and above, didCompleteWithError is not called hence we use task state to detect task completion + // while prior to iOS 15, task state doesn't change to completed hence we use didCompleteWithError to detect task completion + interceptor?.task(task, didCompleteWithError: task.error) + } } open func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { @@ -119,6 +114,41 @@ open class DatadogURLSessionDelegate: NSObject, URLSessionDataDelegate { // NOTE: This delegate method is only called for `URLSessionTasks` created without the completion handler. interceptor?.task(task, didCompleteWithError: error) } + + private func swizzle(firstPartyHosts: FirstPartyHosts) { + do { + try swizzler.swizzle( + interceptResume: { [weak self] task in + guard + let interceptor = self?.interceptor, + let provider = task.dd.delegate as? __URLSessionDelegateProviding, + provider.ddURLSessionDelegate === self // intercept task with self as delegate + else { + return + } + + if let currentRequest = task.currentRequest { + let request = interceptor.intercept(request: currentRequest, additionalFirstPartyHosts: firstPartyHosts) + task.dd.override(currentRequest: request) + } + + interceptor.intercept(task: task, additionalFirstPartyHosts: firstPartyHosts) + } + ) + + try swizzler.swizzle( + interceptCompletionHandler: { [weak self] task, _, error in + self?.interceptor?.task(task, didCompleteWithError: error) + } + ) + } catch { + DD.logger.error("Fails to apply swizzling for instrumenting \(Self.self)", error: error) + } + } + + deinit { + swizzler.unswizzle() + } } @available(*, deprecated, message: "Use `URLSessionInstrumentation.enable(with:)` instead.") diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/NetworkInstrumentationSwizzler.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/NetworkInstrumentationSwizzler.swift new file mode 100644 index 0000000000..f694655db5 --- /dev/null +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/NetworkInstrumentationSwizzler.swift @@ -0,0 +1,72 @@ +/* + * 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 + +/// Swizzles `URLSession*` methods. +internal final class NetworkInstrumentationSwizzler { + let urlSessionSwizzler: URLSessionSwizzler + let urlSessionTaskSwizzler: URLSessionTaskSwizzler + let urlSessionTaskDelegateSwizzler: URLSessionTaskDelegateSwizzler + let urlSessionDataDelegateSwizzler: URLSessionDataDelegateSwizzler + + init() { + let lock = NSRecursiveLock() + urlSessionSwizzler = URLSessionSwizzler(lock: lock) + urlSessionTaskSwizzler = URLSessionTaskSwizzler(lock: lock) + urlSessionTaskDelegateSwizzler = URLSessionTaskDelegateSwizzler(lock: lock) + urlSessionDataDelegateSwizzler = URLSessionDataDelegateSwizzler(lock: lock) + } + + /// Swizzles `URLSession.dataTask(with:completionHandler:)` methods (with `URL` and `URLRequest`). + func swizzle( + interceptCompletionHandler: @escaping (URLSessionTask, Data?, Error?) -> Void + ) throws { + try urlSessionSwizzler.swizzle(interceptCompletionHandler: interceptCompletionHandler) + } + + /// Swizzles `URLSessionTask.resume()` method. + func swizzle( + interceptResume: @escaping (URLSessionTask) -> Void + ) throws { + try urlSessionTaskSwizzler.swizzle(interceptResume: interceptResume) + } + + /// Swizzles methods: + /// - `URLSessionTaskDelegate.urlSession(_:task:didFinishCollecting:)` + /// - `URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)` + func swizzle( + delegateClass: URLSessionTaskDelegate.Type, + interceptDidFinishCollecting: @escaping (URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void, + interceptDidCompleteWithError: @escaping (URLSession, URLSessionTask, Error?) -> Void + ) throws { + try urlSessionTaskDelegateSwizzler.swizzle( + delegateClass: delegateClass, + interceptDidFinishCollecting: interceptDidFinishCollecting, + interceptDidCompleteWithError: interceptDidCompleteWithError + ) + } + + /// Swizzles methods: + /// - `URLSessionDataDelegate.urlSession(_:dataTask:didReceive:)` + func swizzle( + delegateClass: URLSessionDataDelegate.Type, + interceptDidReceive: @escaping (URLSession, URLSessionDataTask, Data) -> Void + ) throws { + try urlSessionDataDelegateSwizzler.swizzle( + delegateClass: delegateClass, + interceptDidReceive: interceptDidReceive + ) + } + + /// Unswizzles all. + func unswizzle() { + urlSessionSwizzler.unswizzle() + urlSessionTaskSwizzler.unswizzle() + urlSessionTaskDelegateSwizzler.unswizzle() + urlSessionDataDelegateSwizzler.unswizzle() + } +} diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionDataDelegateSwizzler.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionDataDelegateSwizzler.swift index eb0b8af960..0c6ef61e94 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionDataDelegateSwizzler.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionDataDelegateSwizzler.swift @@ -8,74 +8,36 @@ import Foundation /// Swizzles `URLSessionDataDelegate` callbacks. internal class URLSessionDataDelegateSwizzler { - private static var _didReceiveMap: [String: DidReceive?] = [:] - static var didReceiveMap: [String: DidReceive?] { - get { - lock.lock() - defer { lock.unlock() } - return _didReceiveMap - } - set { - lock.lock() - defer { lock.unlock() } - _didReceiveMap = newValue - } - } + private let lock: NSLocking + private var didReceive: DidReceive? - private static var lock = NSRecursiveLock() - - static var isBinded: Bool { - lock.lock() - defer { lock.unlock() } - return didReceiveMap.isEmpty == false + init(lock: NSLocking = NSLock()) { + self.lock = lock } - static func bindIfNeeded( + /// Swizzles methods: + /// - `URLSessionDataDelegate.urlSession(_:dataTask:didReceive:)` + func swizzle( delegateClass: URLSessionDataDelegate.Type, interceptDidReceive: @escaping (URLSession, URLSessionDataTask, Data) -> Void ) throws { lock.lock() defer { lock.unlock() } - - let key = MetaTypeExtensions.key(from: delegateClass) - guard didReceiveMap[key] == nil else { - return - } - - try bind(delegateClass: delegateClass, interceptDidReceive: interceptDidReceive) - } - - static func bind( - delegateClass: URLSessionDataDelegate.Type, - interceptDidReceive: @escaping (URLSession, URLSessionDataTask, Data - ) -> Void) throws { - lock.lock() - defer { lock.unlock() } - - let didReceive = try DidReceive.build(klass: delegateClass) - let key = MetaTypeExtensions.key(from: delegateClass) - - didReceive.swizzle(intercept: interceptDidReceive) - didReceiveMap[key] = didReceive + didReceive = try DidReceive.build(klass: delegateClass) + didReceive?.swizzle(intercept: interceptDidReceive) } - static func unbind(delegateClass: URLSessionDataDelegate.Type) { + /// Unswizzles all. + /// + /// This method is called during deinit. + func unswizzle() { lock.lock() - defer { lock.unlock() } - - let key = MetaTypeExtensions.key(from: delegateClass) - didReceiveMap[key]??.unswizzle() - didReceiveMap.removeValue(forKey: key) + didReceive?.unswizzle() + lock.unlock() } - static func unbindAll() { - lock.lock() - defer { lock.unlock() } - - didReceiveMap.forEach { _, didReceive in - didReceive?.unswizzle() - } - didReceiveMap.removeAll() + deinit { + unswizzle() } /// Swizzles `urlSession(_:dataTask:didReceive:)` callback. @@ -84,15 +46,15 @@ internal class URLSessionDataDelegateSwizzler { class DidReceive: MethodSwizzler<@convention(c) (URLSessionDataDelegate, Selector, URLSession, URLSessionDataTask, Data) -> Void, @convention(block) (URLSessionDataDelegate, URLSession, URLSessionDataTask, Data) -> Void> { private static let selector = #selector(URLSessionDataDelegate.urlSession(_:dataTask:didReceive:)) - private let method: FoundMethod + private let method: Method - static func build(klass: URLSessionDataDelegate.Type) throws -> DidReceive { + static func build(klass: AnyClass) throws -> DidReceive { return try DidReceive(selector: self.selector, klass: klass) } private init(selector: Selector, klass: AnyClass) throws { do { - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } catch { // URLSessionDataDelegate doesn't implement the selector, so we inject it and swizzle it let block: @convention(block) (URLSessionDataDelegate, URLSession, URLSessionDataTask, Data) -> Void = { delegate, session, task, data in @@ -108,7 +70,7 @@ internal class URLSessionDataDelegateSwizzler { @ - third argument is an object */ class_addMethod(klass, selector, imp, "v@:@@@") - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } super.init() diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionInstrumentation.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionInstrumentation.swift index 777291eb26..bb3f98cd91 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionInstrumentation.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionInstrumentation.swift @@ -26,7 +26,7 @@ public enum URLSessionInstrumentation { throw ProgrammerError(description: "URLSession tracking must be enabled before enabling URLSessionInstrumentation using either RUM or Trace feature.") } - try feature.bindIfNeeded(configuration: configuration) + try feature.bind(configuration: configuration) } /// Disables URLSession instrumentation. diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionSwizzler.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionSwizzler.swift index 14af26915f..4758689131 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionSwizzler.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionSwizzler.swift @@ -6,94 +6,138 @@ import Foundation -/// Swizzles `URLSession` methods. -internal class URLSessionSwizzler { - private static var _dataTaskWithURLRequestAndCompletion: DataTaskWithURLRequestAndCompletion? - static var dataTaskWithURLRequestAndCompletion: DataTaskWithURLRequestAndCompletion? { - get { - lock.lock() - defer { lock.unlock() } - return _dataTaskWithURLRequestAndCompletion - } - set { - lock.lock() - defer { lock.unlock() } - _dataTaskWithURLRequestAndCompletion = newValue - } - } - - private static var lock = NSRecursiveLock() - - static var isBinded: Bool { - lock.lock() - defer { lock.unlock() } - return dataTaskWithURLRequestAndCompletion != nil +/// Swizzles `URLSession*` methods. +internal final class URLSessionSwizzler { + private let lock: NSLocking + private var dataTaskURLRequestCompletionHandler: DataTaskURLRequestCompletionHandler? + private var dataTaskURLCompletionHandler: DataTaskURLCompletionHandler? + + init(lock: NSLocking = NSLock()) { + self.lock = lock } - static func bindIfNeeded( - interceptURLRequest: @escaping (URLRequest) -> URLRequest?, - interceptTask: @escaping (URLSessionTask) -> Void + /// Swizzles `URLSession.dataTask(with:completionHandler:)` methods (with `URL` and `URLRequest`). + func swizzle( + interceptCompletionHandler: @escaping (URLSessionTask, Data?, Error?) -> Void ) throws { lock.lock() defer { lock.unlock() } - - guard dataTaskWithURLRequestAndCompletion == nil else { - return - } - - try bind(interceptURLRequest: interceptURLRequest, interceptTask: interceptTask) + dataTaskURLRequestCompletionHandler = try DataTaskURLRequestCompletionHandler.build() + dataTaskURLRequestCompletionHandler?.swizzle(interceptCompletion: interceptCompletionHandler) + dataTaskURLCompletionHandler = try DataTaskURLCompletionHandler.build() + dataTaskURLCompletionHandler?.swizzle(interceptCompletion: interceptCompletionHandler) } - static func bind( - interceptURLRequest: @escaping (URLRequest) -> URLRequest?, - interceptTask: @escaping (URLSessionTask) -> Void - ) throws { + /// Unswizzles all. + /// + /// This method is called during deinit. + func unswizzle() { lock.lock() - defer { lock.unlock() } - - self.dataTaskWithURLRequestAndCompletion = try DataTaskWithURLRequestAndCompletion.build() - dataTaskWithURLRequestAndCompletion?.swizzle(interceptRequest: interceptURLRequest, interceptTask: interceptTask) + dataTaskURLRequestCompletionHandler?.unswizzle() + dataTaskURLCompletionHandler?.unswizzle() + lock.unlock() } - static func unbind() { - lock.lock() - defer { lock.unlock() } - dataTaskWithURLRequestAndCompletion?.unswizzle() - dataTaskWithURLRequestAndCompletion = nil + deinit { + unswizzle() } typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void - /// Swizzles `URLSession.dataTask(with:completionHandler:)` method. - class DataTaskWithURLRequestAndCompletion: MethodSwizzler<@convention(c) (URLSession, Selector, URLRequest, CompletionHandler?) -> URLSessionDataTask, @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask> { + /// Swizzles `URLSession.dataTask(with:completionHandler:)` (with `URLRequest`) method. + class DataTaskURLRequestCompletionHandler: MethodSwizzler<@convention(c) (URLSession, Selector, URLRequest, CompletionHandler?) -> URLSessionDataTask, @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask> { private static let selector = #selector( URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URLRequest, @escaping CompletionHandler) -> URLSessionDataTask ) - private let method: FoundMethod + private let method: Method - static func build() throws -> DataTaskWithURLRequestAndCompletion { - return try DataTaskWithURLRequestAndCompletion( + static func build() throws -> DataTaskURLRequestCompletionHandler { + return try DataTaskURLRequestCompletionHandler( selector: self.selector, klass: URLSession.self ) } private init(selector: Selector, klass: AnyClass) throws { - self.method = try Self.findMethod(with: selector, in: klass) + self.method = try dd_class_getInstanceMethod(klass, selector) super.init() } func swizzle( - interceptRequest: @escaping (URLRequest) -> URLRequest?, - interceptTask: @escaping (URLSessionTask) -> Void + interceptCompletion: @escaping (URLSessionTask, Data?, Error?) -> Void ) { typealias Signature = @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask swizzle(method) { previousImplementation -> Signature in return { session, request, completionHandler -> URLSessionDataTask in - let interceptedRequest = interceptRequest(request) ?? request - let task = previousImplementation(session, Self.selector, interceptedRequest, completionHandler) - interceptTask(task) + guard let completionHandler = completionHandler else { + // The `completionHandler` can be `nil` in two cases: + // - on iOS 11 or 12, where `dataTask(with:)` (for `URL` and `URLRequest`) calls + // the `dataTask(with:completionHandler:)` (for `URLRequest`) internally by nullifying the completion block. + // - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing + // `nil` as the `completionHandler` (it produces a warning, but compiles). + return previousImplementation(session, Self.selector, request, completionHandler) + } + + var _task: URLSessionDataTask? + let task = previousImplementation(session, Self.selector, request) { data, response, error in + completionHandler(data, response, error) + + if let task = _task { // sanity check, should always succeed + interceptCompletion(task, data, error) + } + } + _task = task + return task + } + } + } + } + + /// Swizzles `URLSession.dataTask(with:completionHandler:)` (with `URL`) method. + class DataTaskURLCompletionHandler: MethodSwizzler<@convention(c) (URLSession, Selector, URL, CompletionHandler?) -> URLSessionDataTask, @convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask> { + private static let selector = #selector( + URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URL, @escaping CompletionHandler) -> URLSessionDataTask + ) + + private let method: Method + + static func build() throws -> DataTaskURLCompletionHandler { + return try DataTaskURLCompletionHandler( + selector: self.selector, + klass: URLSession.self + ) + } + + private init(selector: Selector, klass: AnyClass) throws { + self.method = try dd_class_getInstanceMethod(klass, selector) + super.init() + } + + func swizzle( + interceptCompletion: @escaping (URLSessionTask, Data?, Error?) -> Void + ) { + typealias Signature = @convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask + swizzle(method) { previousImplementation -> Signature in + return { session, url, completionHandler -> URLSessionDataTask in + guard let completionHandler = completionHandler else { + // The `completionHandler` can be `nil` in two cases: + // - on iOS 11 or 12, where `dataTask(with:)` (for `URL` and `URLRequest`) calls + // the `dataTask(with:completionHandler:)` (for `URLRequest`) internally by nullifying the completion block. + // - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing + // `nil` as the `completionHandler` (it produces a warning, but compiles). + return previousImplementation(session, Self.selector, url, completionHandler) + } + + var _task: URLSessionDataTask? + let task = previousImplementation(session, Self.selector, url) { data, response, error in + completionHandler(data, response, error) + + if let task = _task { // sanity check, should always succeed + interceptCompletion(task, data, error) + } + } + _task = task return task } } diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTask+Tracking.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTask+Tracking.swift index befdfd1673..6c3f19035b 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTask+Tracking.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTask+Tracking.swift @@ -6,23 +6,31 @@ import Foundation -private var sessionFirstPartyHostsKey: UInt8 = 31 -internal extension URLSessionTask { - /// Returns the first party hosts for this task. - var firstPartyHosts: FirstPartyHosts { - get { - return sessionFirstPartyHosts ?? .init() - } +extension URLSessionTask: DatadogExtended {} +extension DatadogExtension where ExtendedType: URLSessionTask { + /// Overrides the current request of the ``URLSessionTask``. + /// + /// The current request must be overriden before the task resumes. + /// + /// - Parameter request: The new request. + func override(currentRequest request: URLRequest) { + // The `URLSessionTask` is Key-Value Coding compliant and we can + // set the `currentRequest` property + type.setValue(request, forKey: "currentRequest") } - /// Extension property for storing first party hosts passed from `URLSession` to `URLSessionTask`. - /// This is used for `URLSessionTask` based APIs. - var sessionFirstPartyHosts: FirstPartyHosts? { - set { - objc_setAssociatedObject(self, &sessionFirstPartyHostsKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) + /// Returns the delegate instance the task is reporting to. + var delegate: URLSessionDelegate? { + if #available(iOS 15.0, tvOS 15.0, *), let delegate = type.delegate { + return delegate } - get { - return objc_getAssociatedObject(self, &sessionFirstPartyHostsKey) as? FirstPartyHosts + + // The `URLSessionTask` is Key-Value Coding compliant and retains a + // `session` property + guard let session = type.value(forKey: "session") as? URLSession else { + return nil } + + return session.delegate } } diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegate+Tracking.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegate+Tracking.swift deleted file mode 100644 index 714a8cec8e..0000000000 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegate+Tracking.swift +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation - -private var coreKey: UInt8 = 43 - -public extension URLSessionDelegate { - /// Returns the `DatadogCore` for this delegate. - weak var core: DatadogCoreProtocol? { - set { - objc_setAssociatedObject(self, &coreKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_ASSIGN) - } - get { - return objc_getAssociatedObject(self, &coreKey) as? DatadogCoreProtocol - } - } - - /// Returns the `URLSessionInterceptor` for this delegate. - var interceptor: URLSessionInterceptor? { - let core = self.core ?? CoreRegistry.default - return URLSessionInterceptor.shared(in: core) - } -} diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegateSwizzler.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegateSwizzler.swift index 3abcdb0afb..48194b9b72 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegateSwizzler.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskDelegateSwizzler.swift @@ -8,112 +8,49 @@ import Foundation /// Swizzles `URLSessionTaskDelegate` callbacks. internal class URLSessionTaskDelegateSwizzler { - private static var _didFinishCollectingMap: [String: DidFinishCollecting?] = [:] - static var didFinishCollectingMap: [String: DidFinishCollecting?] { - get { - lock.lock() - defer { lock.unlock() } - return _didFinishCollectingMap - } - set { - lock.lock() - defer { lock.unlock() } - _didFinishCollectingMap = newValue - } - } - private static var lock = NSRecursiveLock() - - private static var _didCompleteWithErrorMap: [String: DidCompleteWithError?] = [:] - static var didCompleteWithErrorMap: [String: DidCompleteWithError?] { - get { - lock.lock() - defer { lock.unlock() } - return _didCompleteWithErrorMap - } - set { - lock.lock() - defer { lock.unlock() } - _didCompleteWithErrorMap = newValue - } - } - - static var isBinded: Bool { - lock.lock() - defer { lock.unlock() } - return didFinishCollectingMap.isEmpty == false || didCompleteWithErrorMap.isEmpty == false - } - - static func bindIfNeeded( - delegateClass: AnyClass, - interceptDidFinishCollecting: @escaping (URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void, - interceptDidCompleteWithError: @escaping (URLSession, URLSessionTask, Error?) -> Void - ) throws { - lock.lock() - defer { lock.unlock() } - - let key = MetaTypeExtensions.key(from: delegateClass) - guard didFinishCollectingMap[key] == nil || didCompleteWithErrorMap[key] == nil else { - return - } + private let lock: NSLocking + private var didFinishCollecting: DidFinishCollecting? + private var didCompleteWithError: DidCompleteWithError? - try bind( - delegateClass: delegateClass, - interceptDidFinishCollecting: interceptDidFinishCollecting, - interceptDidCompleteWithError: interceptDidCompleteWithError - ) + init(lock: NSLocking = NSLock()) { + self.lock = lock } - static func bind( - delegateClass: AnyClass, + /// Swizzles methods: + /// - `URLSessionTaskDelegate.urlSession(_:task:didFinishCollecting:)` + /// - `URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)` + func swizzle( + delegateClass: URLSessionTaskDelegate.Type, interceptDidFinishCollecting: @escaping (URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void, interceptDidCompleteWithError: @escaping (URLSession, URLSessionTask, Error?) -> Void ) throws { lock.lock() defer { lock.unlock() } - - let didFinishCollecting = try DidFinishCollecting.build(klass: delegateClass) - let key = MetaTypeExtensions.key(from: delegateClass) - - didFinishCollecting.swizzle(intercept: interceptDidFinishCollecting) - didFinishCollectingMap[key] = didFinishCollecting - - let didCompleteWithError = try DidCompleteWithError.build(klass: delegateClass) - didCompleteWithError.swizzle(intercept: interceptDidCompleteWithError) - didCompleteWithErrorMap[key] = didCompleteWithError + didFinishCollecting = try DidFinishCollecting.build(klass: delegateClass) + didCompleteWithError = try DidCompleteWithError.build(klass: delegateClass) + didFinishCollecting?.swizzle(intercept: interceptDidFinishCollecting) + didCompleteWithError?.swizzle(intercept: interceptDidCompleteWithError) } - static func unbind(delegateClass: AnyClass) { + /// Unswizzles all. + /// + /// This method is called during deinit. + func unswizzle() { lock.lock() - defer { lock.unlock() } - - let key = MetaTypeExtensions.key(from: delegateClass) - didFinishCollectingMap[key]??.unswizzle() - didFinishCollectingMap[key] = nil - - didCompleteWithErrorMap[key]??.unswizzle() - didCompleteWithErrorMap[key] = nil + didFinishCollecting?.unswizzle() + didCompleteWithError?.unswizzle() + lock.unlock() } - static func unbindAll() { - lock.lock() - defer { lock.unlock() } - - didFinishCollectingMap.forEach { _, didFinishCollecting in - didFinishCollecting?.unswizzle() - } - didFinishCollectingMap.removeAll() - - didCompleteWithErrorMap.forEach { _, didCompleteWithError in - didCompleteWithError?.unswizzle() - } - didCompleteWithErrorMap.removeAll() + deinit { + unswizzle() } /// Swizzles `URLSessionTaskDelegate.urlSession(_:task:didFinishCollecting:)` method. class DidFinishCollecting: MethodSwizzler<@convention(c) (URLSessionTaskDelegate, Selector, URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void, @convention(block) (URLSessionTaskDelegate, URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void> { private static let selector = #selector(URLSessionTaskDelegate.urlSession(_:task:didFinishCollecting:)) - private let method: FoundMethod + private let method: Method static func build(klass: AnyClass) throws -> DidFinishCollecting { return try DidFinishCollecting(selector: self.selector, klass: klass) @@ -121,7 +58,7 @@ internal class URLSessionTaskDelegateSwizzler { private init(selector: Selector, klass: AnyClass) throws { do { - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } catch { // URLSessionTaskDelegate doesn't implement the selector, so we inject it and swizzle it let block: @convention(block) (URLSessionTaskDelegate, URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void = { delegate, session, task, metrics in @@ -137,7 +74,7 @@ internal class URLSessionTaskDelegateSwizzler { @ - third argument is an object */ class_addMethod(klass, selector, imp, "v@:@@@") - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } super.init() @@ -157,7 +94,7 @@ internal class URLSessionTaskDelegateSwizzler { class DidCompleteWithError: MethodSwizzler<@convention(c) (URLSessionTaskDelegate, Selector, URLSession, URLSessionTask, Error?) -> Void, @convention(block) (URLSessionTaskDelegate, URLSession, URLSessionTask, Error?) -> Void> { private static let selector = #selector(URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)) - private let method: FoundMethod + private let method: Method static func build(klass: AnyClass) throws -> DidCompleteWithError { return try DidCompleteWithError(selector: self.selector, klass: klass) @@ -165,7 +102,7 @@ internal class URLSessionTaskDelegateSwizzler { private init(selector: Selector, klass: AnyClass) throws { do { - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } catch { // URLSessionTaskDelegate doesn't implement the selector, so we inject it and swizzle it let block: @convention(block) (URLSessionTaskDelegate, URLSession, URLSessionTask, Error?) -> Void = { delegate, session, task, error in @@ -181,7 +118,7 @@ internal class URLSessionTaskDelegateSwizzler { @ - third argument is an object */ class_addMethod(klass, selector, imp, "v@:@@@") - method = try Self.findMethod(with: selector, in: klass) + method = try dd_class_getInstanceMethod(klass, selector) } super.init() diff --git a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskSwizzler.swift b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskSwizzler.swift index 90b50f1841..68ad2ef713 100644 --- a/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskSwizzler.swift +++ b/DatadogInternal/Sources/NetworkInstrumentation/URLSession/URLSessionTaskSwizzler.swift @@ -6,69 +6,49 @@ import Foundation -/// Swizzles `URLSessionTask` methods. -internal class URLSessionTaskSwizzler { - private static var _resume: Resume? - static var resume: Resume? { - get { - lock.lock() - defer { lock.unlock() } - return _resume - } - set { - lock.lock() - defer { lock.unlock() } - _resume = newValue - } - } - - private static var lock = NSRecursiveLock() +internal final class URLSessionTaskSwizzler { + private let lock: NSLocking + private var taskResume: TaskResume? - static var isBinded: Bool { - lock.lock() - defer { lock.unlock() } - return resume != nil + init(lock: NSLocking = NSLock()) { + self.lock = lock } - static func bindIfNeeded(interceptResume: @escaping (URLSessionTask) -> Void) throws { + /// Swizzles `URLSessionTask.resume()` method. + func swizzle( + interceptResume: @escaping (URLSessionTask) -> Void + ) throws { lock.lock() defer { lock.unlock() } - - guard resume == nil else { - return - } - - try bind(interceptResume: interceptResume) + taskResume = try TaskResume.build() + taskResume?.swizzle(intercept: interceptResume) } - static func bind(interceptResume: @escaping (URLSessionTask) -> Void) throws { + /// Unswizzles all. + /// + /// This method is called during deinit. + func unswizzle() { lock.lock() - defer { lock.unlock() } - - self.resume = try Resume.build() - - resume?.swizzle(intercept: interceptResume) + taskResume?.unswizzle() + lock.unlock() } - static func unbind() { - lock.lock() - defer { lock.unlock() } - resume?.unswizzle() - resume = nil + deinit { + unswizzle() } /// Swizzles `URLSessionTask.resume()` method. - class Resume: MethodSwizzler<@convention(c) (URLSessionTask, Selector) -> Void, @convention(block) (URLSessionTask) -> Void> { + class TaskResume: MethodSwizzler<@convention(c) (URLSessionTask, Selector) -> Void, @convention(block) (URLSessionTask) -> Void> { private static let selector = #selector(URLSessionTask.resume) - private let method: FoundMethod + private let method: Method - static func build() throws -> Resume { - return try Resume(selector: self.selector, klass: URLSessionTask.self) + static func build() throws -> TaskResume { + return try TaskResume(selector: self.selector, klass: URLSessionTask.self) } private init(selector: Selector, klass: AnyClass) throws { - self.method = try Self.findMethod(with: selector, in: klass) + self.method = try dd_class_getInstanceMethod(klass, selector) super.init() } diff --git a/DatadogInternal/Sources/Swizzling/MethodSwizzler.swift b/DatadogInternal/Sources/Swizzling/MethodSwizzler.swift index 028987633f..173c6e80e8 100644 --- a/DatadogInternal/Sources/Swizzling/MethodSwizzler.swift +++ b/DatadogInternal/Sources/Swizzling/MethodSwizzler.swift @@ -6,127 +6,186 @@ import Foundation -public enum Swizzling { - /// The list of active swizzlings to ensure integrity in unit tests. - @ReadWriteLock - public static var activeSwizzlingNames: [String] = [] -} +/// Swizzling interface holds references and hierarchy of swizzled +/// methods. +internal enum Swizzling { + /// List of currently swizzled methods. + static var methods: [Method] { + sync { Array($0.keys) } + } -open class MethodSwizzler { - public struct FoundMethod: Hashable { - let method: Method - let klass: AnyClass + /// Describes the current swizzled methods. + static var description: String { + methods.map { method_getName($0) }.description + } - fileprivate init(method: Method, klass: AnyClass) { - self.method = method - self.klass = klass - } + /// The hierarchy of swizzling per method. + private static var swizzlings: [Method: MethodSwizzling] = [:] - public static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool { - let methodParity = (lhs.method == rhs.method) - let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass)) - return methodParity && classParity - } + /// lock for synchronizing `swizzlings` mutuations. + private static let lock = NSLock() - public func hash(into hasher: inout Hasher) { - let methodName = NSStringFromSelector(method_getName(method)) - let klassName = NSStringFromClass(klass) - let identifier = "\(methodName)|||\(klassName)" - hasher.combine(identifier) - } + /// Synchronization point to access the swizzling nodes. + @discardableResult + fileprivate static func sync(block: (inout [Method: MethodSwizzling]) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return block(&swizzlings) } +} - private var implementationCache: [FoundMethod: IMP] = [:] - var swizzledMethods: [FoundMethod] { - return Array(implementationCache.keys) +/// Linked list of swizzled implementations. +/// +/// This object hold the previous (origin) implementation of a +/// method, the override closure reference, and a reference to its +/// parent closure. +private final class MethodSwizzling { + /// original implementation + let origin: IMP + /// type-erased override closure + let override: OverrideBox + /// parent swizzling + let parent: MethodSwizzling? + + init(origin: IMP, override: OverrideBox, parent: MethodSwizzling? = nil) { + self.origin = origin + self.override = override + self.parent = parent } +} - public static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod { - /// NOTE: RUMM-452 as we never add/remove methods/classes at runtime, - /// search operation doesn't have to wrapped in sync {...} although it's visible in the interface - var headKlass: AnyClass? = klass - while let someKlass = headKlass { - if let foundMethod = findMethod(with: selector, in: someKlass) { - return FoundMethod(method: foundMethod, klass: someKlass) - } - headKlass = class_getSuperclass(headKlass) - } - throw InternalError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))") +/// Reference to type-erased override closure. +private final class OverrideBox { + let closure: Any + init(_ closure: Any) { + self.closure = closure } +} - public init() { } +open class MethodSwizzler { + /// A `MethodOverride` associates an override reference to a method + /// of the Objective-C runtime. + private typealias MethodOverride = (method: Method, `override`: OverrideBox) - func originalImplementation(of found: FoundMethod) -> TypedIMP { - return sync { - let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method) - return unsafeBitCast(originalImp, to: TypedIMP.self) - } - } + /// List of overrides managed by this instance. + private var overrides: [MethodOverride] = [] - public func swizzle( - _ foundMethod: FoundMethod, - impProvider: (TypedIMP) -> TypedBlockIMP - ) { - sync { - let currentIMP = method_getImplementation(foundMethod.method) - let current_typedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self) - let newImpBlock: TypedBlockIMP = impProvider(current_typedIMP) - let newImp: IMP = imp_implementationWithBlock(newImpBlock) - - set(newIMP: newImp, for: foundMethod) - - #if DD_SDK_COMPILED_FOR_TESTING - Swizzling.activeSwizzlingNames.append(foundMethod.swizzlingName) - #endif + public init() { } + + /// Swizzle a method with a closure. + /// + /// - Parameters: + /// - method: The method pointer to swizzle. + /// - override: The closure to apply. + /// + /// - Complexity: O(1) on average, over many calls to `swizzle(_:,override:)` on the + /// same array. When a swizzler needs to reallocate storage before swizzling, swizzling is O(*n*), + /// where *n* is the number of method swizzling managed by this instance. + public func swizzle(_ method: Method, override: @escaping (Signature) -> Override) { + Swizzling.sync { swizzlings in + let origin = method_override(method, override) + let override = OverrideBox(override) + + swizzlings[method] = MethodSwizzling( + origin: origin, + override: override, + parent: swizzlings[method] + ) + + overrides.append((method, override)) } } - /// Removes swizzling and resets the method to its original implementation. + /// Removes swizzling and resets the method to its previous implementation. + /// + /// This method will remove all swizzles that have been created by the instance + /// only. Other overrides will stay in the callstack. + /// + /// - Complexity: O(*n*), where *n* is the number of the swizzle per method. public func unswizzle() { - for foundMethod in swizzledMethods { - let originalTypedIMP = originalImplementation(of: foundMethod) - let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self) - method_setImplementation(foundMethod.method, originalIMP) - - Swizzling.activeSwizzlingNames.removeAll { $0 == foundMethod.swizzlingName } + Swizzling.sync { swizzlings in + while let (method, override) = overrides.popLast() { + guard let swizzling = swizzlings[method] else { + continue + } + + swizzlings[method] = _unswizzle( + method: method, + override: override, + swizzling: swizzling + ) + } } } - // MARK: - Private methods - - @discardableResult - private func sync(block: () -> T) -> T { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - return block() - } - - private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? { - var methodsCount: UInt32 = 0 - let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 } - guard let methods: UnsafeMutablePointer = class_copyMethodList(klass, methodsCountPtr) else { - return nil + /// Unswizzle a method override. + /// + /// If found, the given override will be remove from the hierachy and swizzling will + /// be re-applied for children to also remove the override from the callstack. + /// + /// - Parameters: + /// - method: The method to unswizzle. + /// - override: The override closure to remove from swizzling. + /// - swizzling: The swizzling list. + /// - Returns: The new swizzling hierarchy if any. + private func _unswizzle(method: Method, override: OverrideBox, swizzling: MethodSwizzling) -> MethodSwizzling? { + // reset the method to its previous implementation + method_setImplementation(method, swizzling.origin) + // if override is found, stop the recursion and remove the node + // from the list by returning the parent + if swizzling.override === override { + return swizzling.parent } - defer { - free(methods) + // if override is not found, go to parent (depth-first traversal) + let parent = swizzling.parent.flatMap { + _unswizzle(method: method, override: override, swizzling: $0) } - for index in 0.. Override else { + // we should never get here as the closure will always + // satify the type: remove the node by returning its + // parent + return swizzling.parent } - return nil + // re-apply swizzling for current override + return MethodSwizzling( + origin: method_override(method, override), + override: swizzling.override, + parent: parent + ) } - private func set(newIMP: IMP, for found: FoundMethod) { - if implementationCache[found] == nil { - implementationCache[found] = method_getImplementation(found.method) - } - method_setImplementation(found.method, newIMP) + /// Overrides the implementation of a method. + /// + /// - Parameters: + /// - method: The methods to override. + /// - override: The overriding closure. + /// - Returns: The previous implementation of the method. + private func method_override(_ method: Method, _ override: @escaping (Signature) -> Override) -> IMP { + let org_imp = method_getImplementation(method) + let org = unsafeBitCast(org_imp, to: Signature.self) + let ovr: Override = override(org) + let ovr_imp: IMP = imp_implementationWithBlock(ovr) + return method_setImplementation(method, ovr_imp) + } +} + +extension MethodSwizzler: CustomDebugStringConvertible { + public var debugDescription: String { + """ + The MethodSwizzler holds swizzling for: + \(overrides.map { method_getName($0.method) }.description) + """ } } -internal extension MethodSwizzler.FoundMethod { - var swizzlingName: String { "\(klass).\(method_getName(method))" } +// MARK: - Find Method + +public func dd_class_getInstanceMethod(_ cls: AnyClass, _ name: Selector) throws -> Method { + guard let method = class_getInstanceMethod(cls, name) else { + throw InternalError(description: "\(NSStringFromSelector(name)) is not found in \(NSStringFromClass(cls))") + } + + return method } diff --git a/DatadogInternal/Sources/Telemetry/Telemetry.swift b/DatadogInternal/Sources/Telemetry/Telemetry.swift index 293c0e1b09..96d39ecceb 100644 --- a/DatadogInternal/Sources/Telemetry/Telemetry.swift +++ b/DatadogInternal/Sources/Telemetry/Telemetry.swift @@ -10,6 +10,8 @@ public struct ConfigurationTelemetry: Equatable { public let actionNameAttribute: String? public let allowFallbackToLocalStorage: Bool? public let allowUntrustedEvents: Bool? + public let backgroundTasksEnabled: Bool? + public let batchProcessingLevel: Int64? public let batchSize: Int64? public let batchUploadFrequency: Int64? public let dartVersion: String? @@ -178,6 +180,8 @@ extension Telemetry { actionNameAttribute: String? = nil, allowFallbackToLocalStorage: Bool? = nil, allowUntrustedEvents: Bool? = nil, + backgroundTasksEnabled: Bool? = nil, + batchProcessingLevel: Int64? = nil, batchSize: Int64? = nil, batchUploadFrequency: Int64? = nil, dartVersion: String? = nil, @@ -227,6 +231,8 @@ extension Telemetry { actionNameAttribute: actionNameAttribute, allowFallbackToLocalStorage: allowFallbackToLocalStorage, allowUntrustedEvents: allowUntrustedEvents, + backgroundTasksEnabled: backgroundTasksEnabled, + batchProcessingLevel: batchProcessingLevel, batchSize: batchSize, batchUploadFrequency: batchUploadFrequency, dartVersion: dartVersion, @@ -331,6 +337,8 @@ extension ConfigurationTelemetry { actionNameAttribute: other.actionNameAttribute ?? actionNameAttribute, allowFallbackToLocalStorage: other.allowFallbackToLocalStorage ?? allowFallbackToLocalStorage, allowUntrustedEvents: other.allowUntrustedEvents ?? allowUntrustedEvents, + backgroundTasksEnabled: other.backgroundTasksEnabled ?? backgroundTasksEnabled, + batchProcessingLevel: other.batchProcessingLevel ?? batchProcessingLevel, batchSize: other.batchSize ?? batchSize, batchUploadFrequency: other.batchUploadFrequency ?? batchUploadFrequency, dartVersion: other.dartVersion ?? dartVersion, diff --git a/DatadogInternal/Sources/Utils/MetaTypeExtensions.swift b/DatadogInternal/Sources/Utils/MetaTypeExtensions.swift deleted file mode 100644 index 11079814be..0000000000 --- a/DatadogInternal/Sources/Utils/MetaTypeExtensions.swift +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-2020 Datadog, Inc. - */ - -import Foundation - -internal enum MetaTypeExtensions { - static func key(from anyClass: Any) -> String { - let fullName = String(reflecting: anyClass) - - // fullName may have infix like "(unknown context at $1130f7094)", remove everything inside parenthesis including parenthesis - // Read more: https://github.com/apple/swift/issues/49336 - var key = "" - var parenthesisCount = 0 - for char in fullName { - if char == "(" { - parenthesisCount += 1 - } else if char == ")" { - parenthesisCount -= 1 - } else if parenthesisCount == 0 { - key.append(char) - } - } - - // replace multiple consecutive . with single . - var sanitizedKey = "" - for char in key { - if sanitizedKey.last == "." && char == "." { - continue - } - sanitizedKey.append(char) - } - - return sanitizedKey - } -} diff --git a/DatadogInternal/Sources/Utils/SwiftExtensions.swift b/DatadogInternal/Sources/Utils/SwiftExtensions.swift index 3526e2e3b6..5d661ba5c8 100644 --- a/DatadogInternal/Sources/Utils/SwiftExtensions.swift +++ b/DatadogInternal/Sources/Utils/SwiftExtensions.swift @@ -99,7 +99,7 @@ extension FixedWidthInteger { public init(withNoOverflow floatingPoint: T) { if let converted = Self(exactly: floatingPoint.rounded()) { self = converted - } else { // overflow occured + } else { // overflow occurred switch floatingPoint.sign { case .minus: self = .min case .plus: self = .max diff --git a/DatadogInternal/Tests/NetworkInstrumentation/NetworkInstrumentationFeatureTests.swift b/DatadogInternal/Tests/NetworkInstrumentation/NetworkInstrumentationFeatureTests.swift index 873bc421df..d11f40a2da 100644 --- a/DatadogInternal/Tests/NetworkInstrumentation/NetworkInstrumentationFeatureTests.swift +++ b/DatadogInternal/Tests/NetworkInstrumentation/NetworkInstrumentationFeatureTests.swift @@ -24,7 +24,6 @@ class NetworkInstrumentationFeatureTests: XCTestCase { } override func tearDown() { - core?.get(feature: NetworkInstrumentationFeature.self)?.unbindAll() core = nil super.tearDown() } @@ -212,13 +211,13 @@ class NetworkInstrumentationFeatureTests: XCTestCase { let url2: URL = .mockRandom() session - .dataTask(with: URLRequest(url: url2)) + .dataTask(with: URLRequest(url: url2)) { _,_,_ in } .resume() // Then - waitForExpectations(timeout: 5, handler: nil) - _ = server.waitAndReturnRequests(count: 1) + _ = server.waitAndReturnRequests(count: 2) + waitForExpectations(timeout: 5, handler: nil) let dateAfterAllRequests = Date() XCTAssertEqual(handler.interceptions.count, 2, "Interceptor should record metrics for 2 tasks") @@ -233,6 +232,29 @@ class NetworkInstrumentationFeatureTests: XCTestCase { } } + func testGivenURLSessionWithCustomDelegate_whenNotInstrumented_itDoesNotInterceptTasks() throws { + let server = ServerMock(delivery: .success(response: .mockResponseWith(statusCode: 200), data: Data())) + + // Given + try URLSessionInstrumentation.enableOrThrow(with: .init(delegateClass: MockDelegate.self), in: core) + let session = server.getInterceptedURLSession() // no custom delegate + + // When + let url1: URL = .mockRandom() + session + .dataTask(with: url1) + .resume() + + let url2: URL = .mockRandom() + session + .dataTask(with: URLRequest(url: url2)) + .resume() + + // Then + _ = server.waitAndReturnRequests(count: 2) + XCTAssertEqual(handler.interceptions.count, 0, "Interceptor should not record tasks") + } + func testGivenURLSessionWithDatadogDelegate_whenTaskCompletesWithSuccess_itPassesAllValuesToTheInterceptor() throws { let notifyInterceptionDidStart = expectation(description: "Notify interception did start") let notifyInterceptionDidComplete = expectation(description: "Notify intercepion did complete") @@ -264,11 +286,10 @@ class NetworkInstrumentationFeatureTests: XCTestCase { .resume() // Then - waitForExpectations(timeout: 5, handler: nil) - _ = server.waitAndReturnRequests(count: 1) + _ = server.waitAndReturnRequests(count: 2) + waitForExpectations(timeout: 5, handler: nil) let dateAfterAllRequests = Date() - XCTAssertEqual(handler.interceptions.count, 2, "Interceptor should record metrics for 2 tasks") try [url1, url2].forEach { url in @@ -307,14 +328,12 @@ class NetworkInstrumentationFeatureTests: XCTestCase { // Given let delegate = MockDelegate() try URLSessionInstrumentation.enableOrThrow(with: .init(delegateClass: MockDelegate.self), in: core) - let session = server.getInterceptedURLSession(delegate: delegate) + let session = server.getInterceptedURLSession() // When - let url1: URL = .mockRandom() - _ = try? await session.data(from: url1, delegate: delegate) - - let url2: URL = .mockRandom() - _ = try? await session.data(for: URLRequest(url: url2), delegate: delegate) + _ = try? await session.data(from: .mockRandom(), delegate: delegate) // intercepted + _ = try? await session.data(for: URLRequest(url: .mockRandom()), delegate: delegate) // intercepted + _ = try? await session.data(for: URLRequest(url: .mockRandom())) // not intercepted // Then await dd_fulfillment( @@ -326,7 +345,7 @@ class NetworkInstrumentationFeatureTests: XCTestCase { enforceOrder: true ) - _ = server.waitAndReturnRequests(count: 2) + _ = server.waitAndReturnRequests(count: 3) let dateAfterAllRequests = Date() @@ -340,6 +359,47 @@ class NetworkInstrumentationFeatureTests: XCTestCase { } } + func testGivenURLSessionTask_withCustomDelegate_itInterceptsRequests() throws { + // pre iOS 15 cannot set delegate per task + guard #available(iOS 15, tvOS 15, *) else { + return + } + + let notifyInterceptionDidComplete = expectation(description: "Notify intercepion did complete") + notifyInterceptionDidComplete.expectedFulfillmentCount = 2 + handler.onInterceptionDidComplete = { _ in notifyInterceptionDidComplete.fulfill() } + + let server = ServerMock( + delivery: .success(response: .mockResponseWith(statusCode: 200), data: .mock(ofSize: 10)), + skipIsMainThreadCheck: true + ) + + // Given + let delegate1 = MockDelegate() + let delegate2 = MockDelegate2() + try URLSessionInstrumentation.enableOrThrow(with: .init(delegateClass: MockDelegate.self), in: core) + + let session = server.getInterceptedURLSession() + + // When + let task1 = session.dataTask(with: URL.mockWith(url: "https://www.foo.com/1")) // intercepted + task1.delegate = delegate1 + task1.resume() + + let task2 = session.dataTask(with: URL.mockWith(url: "https://www.foo.com/2")) // intercepted + task2.delegate = delegate1 + task2.resume() + + let task3 = session.dataTask(with: URL.mockWith(url: "https://www.foo.com/3")) // not intercepted + task3.delegate = delegate2 + task3.resume() + + // Then + _ = server.waitAndReturnRequests(count: 3) + waitForExpectations(timeout: 5, handler: nil) + XCTAssertEqual(handler.interceptions.count, 2, "Interceptor should intercept 2 tasks") + } + // MARK: - Usage @available(*, deprecated) @@ -389,6 +449,23 @@ class NetworkInstrumentationFeatureTests: XCTestCase { XCTAssertNil(delegate.interceptor) } + func testWhenEnableInstrumentationOnTheSameDelegate_thenItPrintsAWarning() { + let dd = DD.mockWith(logger: CoreLoggerMock()) + defer { dd.reset() } + + URLSessionInstrumentation.enable(with: .init(delegateClass: MockDelegate.self), in: core) + URLSessionInstrumentation.enable(with: .init(delegateClass: MockDelegate.self), in: core) + + // Then + XCTAssertEqual( + dd.logger.warnLog?.message, + """ + The delegate class MockDelegate is already instrumented. + The previous instrumentation will be disabled in favor of the new one. + """ + ) + } + // MARK: - URLRequest Interception func testGivenOpenTracing_whenInterceptingRequests_itInjectsTrace() throws { @@ -515,6 +592,8 @@ class NetworkInstrumentationFeatureTests: XCTestCase { @available(*, deprecated) func testGivenDelegateSubclass_whenInterceptingRequests_itDetectFirstPartyHost() throws { let notifyInterceptionDidStart = expectation(description: "Notify interception did start") + notifyInterceptionDidStart.expectedFulfillmentCount = 2 + handler.onInterceptionDidStart = { _ in notifyInterceptionDidStart.fulfill() } let server = ServerMock(delivery: .success(response: .mockResponseWith(statusCode: 200), data: .mock(ofSize: 10))) @@ -539,14 +618,23 @@ class NetworkInstrumentationFeatureTests: XCTestCase { .dataTask(with: request) .resume() + session + .dataTask(with: request) { _,_,_ in } + .resume() + // Then waitForExpectations(timeout: 5, handler: nil) _ = server.waitAndReturnRequests(count: 1) + + // release the delegate to unswizzle + session.finishTasksAndInvalidate() } @available(*, deprecated) func testGivenCompositeDelegate_whenInterceptingRequests_itDetectFirstPartyHost() throws { let notifyInterceptionDidStart = expectation(description: "Notify interception did start") + notifyInterceptionDidStart.expectedFulfillmentCount = 2 + handler.onInterceptionDidStart = { _ in notifyInterceptionDidStart.fulfill() } let server = ServerMock(delivery: .success(response: .mockResponseWith(statusCode: 200), data: .mock(ofSize: 10))) @@ -579,9 +667,16 @@ class NetworkInstrumentationFeatureTests: XCTestCase { .dataTask(with: request) .resume() + session + .dataTask(with: request) { _,_,_ in } + .resume() + // Then waitForExpectations(timeout: 5, handler: nil) _ = server.waitAndReturnRequests(count: 1) + + // release the delegate to unswizzle + session.finishTasksAndInvalidate() } // MARK: - Thread Safety @@ -604,7 +699,9 @@ class NetworkInstrumentationFeatureTests: XCTestCase { { feature.intercept(task: tasks.randomElement()!, additionalFirstPartyHosts: nil) }, { feature.task(tasks.randomElement()!, didReceive: .mockRandom()) }, { feature.task(tasks.randomElement()!, didFinishCollecting: .mockAny()) }, - { feature.task(tasks.randomElement()!, didCompleteWithError: nil) } + { feature.task(tasks.randomElement()!, didCompleteWithError: nil) }, + { try? feature.bind(configuration: .init(delegateClass: MockDelegate.self)) }, + { feature.unbind(delegateClass: MockDelegate.self) } ], iterations: 50 ) @@ -613,4 +710,7 @@ class NetworkInstrumentationFeatureTests: XCTestCase { class MockDelegate: NSObject, URLSessionDataDelegate { } + + class MockDelegate2: NSObject, URLSessionDataDelegate { + } } diff --git a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionDataDelegateSwizzlerTests.swift b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionDataDelegateSwizzlerTests.swift index f396ecc5b9..fa2fea5338 100644 --- a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionDataDelegateSwizzlerTests.swift +++ b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionDataDelegateSwizzlerTests.swift @@ -5,117 +5,96 @@ */ import XCTest -@testable import DatadogInternal - -final class URLSessionDataDelegateSwizzlerTests: XCTestCase { - override func tearDown() { - URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate.self) - URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate1.self) - URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate2.self) - XCTAssertEqual(URLSessionDataDelegateSwizzler.didReceiveMap.count, 0) - super.tearDown() - } +@testable import DatadogInternal - func testSwizzling_whenMultipleDelegatesAreGiven() throws { - let delegate1 = MockDelegate1() - let didReceiveData1 = XCTestExpectation(description: "didReceiveData1") - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate1.self) { _, _, _ in - didReceiveData1.fulfill() +class URLSessionDataDelegateSwizzlerTests: XCTestCase { + func testSwizzling_implementedMethods() throws { + class MockDelegate: NSObject, URLSessionDataDelegate { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { } } - let didReceiveData2 = XCTestExpectation(description: "didReceiveData2") - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate2.self) { _, _, _ in - didReceiveData2.fulfill() - } + let delegate = MockDelegate() + let didReceiveData = expectation(description: "didReceiveData") + didReceiveData.assertForOverFulfill = false - let delegate2 = MockDelegate2() - let session1 = URLSession(configuration: .default, delegate: delegate1, delegateQueue: nil) - let task1 = session1.dataTask(with: URL(string: "https://www.datadoghq.com/")!) + // Given + let swizzler = URLSessionDataDelegateSwizzler() - let session2 = URLSession(configuration: .default, delegate: delegate2, delegateQueue: nil) - let task2 = session2.dataTask(with: URL(string: "https://www.datadoghq.com/")!) + try swizzler.swizzle( + delegateClass: MockDelegate.self, + interceptDidReceive: { _, _, _ in + didReceiveData.fulfill() + } + ) - task1.resume() - task2.resume() + // When + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // intercepted - wait(for: [didReceiveData1, didReceiveData2], timeout: 5) + wait(for: [didReceiveData], timeout: 5) } - func testSwizzling_whenDidReceiveDataIsImplemented() throws { + func testSwizzling_whenMethodsNotImplemented() throws { class MockDelegate: NSObject, URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - } } let delegate = MockDelegate() - let expectation = XCTestExpectation(description: "didReceiveData") + let didReceiveData = expectation(description: "didReceiveData") + didReceiveData.assertForOverFulfill = false - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self) { _, _, _ in - expectation.fulfill() - } + // Given + let swizzler = URLSessionDataDelegateSwizzler() + try swizzler.swizzle( + delegateClass: MockDelegate.self, + interceptDidReceive: { _, _, _ in + didReceiveData.fulfill() + } + ) + + // When let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - let task = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - task.resume() + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // intercepted - wait(for: [expectation], timeout: 5) + wait(for: [didReceiveData], timeout: 5) } - func testSwizzling_whenDidReceiveDataNotImplemented() throws { + func testUnSwizzling() throws { class MockDelegate: NSObject, URLSessionDataDelegate { } let delegate = MockDelegate() - let expectation = XCTestExpectation(description: "didReceiveData") - - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self) { _, _, _ in - expectation.fulfill() - } - - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - let task = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - task.resume() - - wait(for: [expectation], timeout: 5) - } - - func testBindings() throws { - XCTAssertNil(URLSessionDataDelegateSwizzler.didReceiveMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidReceive: { _, _, _ in }) - XCTAssertNotNil(URLSessionDataDelegateSwizzler.didReceiveMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - - try URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidReceive: { _, _, _ in }) - XCTAssertNotNil(URLSessionDataDelegateSwizzler.didReceiveMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) + let expectation = self.expectation(description: "not expected") + expectation.isInverted = true - URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate.self) - XCTAssertNil(URLSessionDataDelegateSwizzler.didReceiveMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - } + // Given + let swizzler = URLSessionDataDelegateSwizzler() - func testConcurrentBinding() throws { - // swiftlint:disable opening_brace trailing_closure - callConcurrently( - closures: [ - { try? URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidReceive: self.intercept(_:dataTask:didReceive:)) }, - { URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate.self) }, - { try? URLSessionDataDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidReceive: self.intercept(_:dataTask:didReceive:)) }, - { URLSessionDataDelegateSwizzler.unbind(delegateClass: MockDelegate.self) }, - ], - iterations: 50 + try swizzler.swizzle( + delegateClass: MockDelegate.self, + interceptDidReceive: { _, _, _ in + expectation.fulfill() + } ) - // swiftlint:enable opening_brace trailing_closure - } - - func intercept(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - } - class MockDelegate: NSObject, URLSessionDataDelegate { - } + // When + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // not intercepted - class MockDelegate1: NSObject, URLSessionDataDelegate { - } + swizzler.unswizzle() - class MockDelegate2: NSObject, URLSessionDataDelegate { + waitForExpectations(timeout: 5) } } diff --git a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionSwizzlerTests.swift b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionSwizzlerTests.swift index ae956a39ef..75671d8f53 100644 --- a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionSwizzlerTests.swift +++ b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionSwizzlerTests.swift @@ -5,103 +5,31 @@ */ import XCTest -@testable import DatadogInternal - -final class URLSessionSwizzlerTests: XCTestCase { - override func tearDown() { - URLSessionSwizzler.unbind() - XCTAssertNil(URLSessionSwizzler.dataTaskWithURLRequestAndCompletion as Any?) - super.tearDown() - } +@testable import DatadogInternal - func testSwizzling_dataTaskWithURLRequestAndCompletion() throws { - let didInterceptRequest = XCTestExpectation(description: "interceptURLRequest") - let didInterceptTask = XCTestExpectation(description: "interceptTask") - try URLSessionSwizzler.bind(interceptURLRequest: { request in - didInterceptRequest.fulfill() - return self.interceptRequest(request: request) - }, interceptTask: { _ in - didInterceptTask.fulfill() - }) +class URLSessionSwizzlerTests: XCTestCase { + func testSwizzling_dataTaskWithCompletion() throws { + let didInterceptCompletion = expectation(description: "interceptCompletion") + didInterceptCompletion.expectedFulfillmentCount = 2 - let session = URLSession(configuration: .default) - let request = URLRequest(url: URL(string: "https://www.datadoghq.com/")!) - let task = session.dataTask(with: request) { _, _, _ in } - task.resume() + let swizzler = URLSessionSwizzler() - wait( - for: [ - didInterceptRequest, - didInterceptTask - ], - timeout: 5, - enforceOrder: true + try swizzler.swizzle( + interceptCompletionHandler: { _, _, _ in + didInterceptCompletion.fulfill() + } ) - } - - func testSwizzling_testSwizzling_dataTaskWithURLRequest() throws { - // runs only on iOS 12 or below - // because on iOS 12 and below `URLSession.dataTask(with:)` is implemented using `URLSession.dataTask(with:completionHandler:)` - if #available(iOS 13.0, *) { - return - } - - let didInterceptRequest = XCTestExpectation(description: "interceptURLRequest") - let didInterceptTask = XCTestExpectation(description: "interceptTask") - try URLSessionSwizzler.bind(interceptURLRequest: { request in - didInterceptRequest.fulfill() - return self.interceptRequest(request: request) - }, interceptTask: { _ in - didInterceptTask.fulfill() - }) let session = URLSession(configuration: .default) - let request = URLRequest(url: URL(string: "https://www.datadoghq.com/")!) - let task = session.dataTask(with: request) - task.resume() - - wait( - for: [ - didInterceptRequest, - didInterceptTask - ], - timeout: 5, - enforceOrder: true - ) - } - - func testBindings() { - XCTAssertNil(URLSessionSwizzler.dataTaskWithURLRequestAndCompletion as Any?) - - try? URLSessionSwizzler.bind(interceptURLRequest: self.interceptRequest(request:), interceptTask: self.interceptTask(task:)) - XCTAssertNotNil(URLSessionSwizzler.dataTaskWithURLRequestAndCompletion as Any?) - - try? URLSessionSwizzler.bind(interceptURLRequest: self.interceptRequest(request:), interceptTask: self.interceptTask(task:)) - XCTAssertNotNil(URLSessionSwizzler.dataTaskWithURLRequestAndCompletion as Any?) - - URLSessionSwizzler.unbind() - XCTAssertNil(URLSessionSwizzler.dataTaskWithURLRequestAndCompletion as Any?) - } + let url = URL(string: "https://www.datadoghq.com/")! + session.dataTask(with: url) { _, _, _ in }.resume() // intercepted + session.dataTask(with: URLRequest(url: url)) { _, _, _ in }.resume() // intercepted - func testConcurrentBinding() throws { - // swiftlint:disable opening_brace trailing_closure - callConcurrently( - closures: [ - { try? URLSessionSwizzler.bind(interceptURLRequest: self.interceptRequest(request:), interceptTask: self.interceptTask(task:)) }, - { URLSessionSwizzler.unbind() }, - { try? URLSessionSwizzler.bind(interceptURLRequest: self.interceptRequest(request:), interceptTask: self.interceptTask(task:)) }, - { URLSessionSwizzler.unbind() }, - ], - iterations: 50 - ) - // swiftlint:enable opening_brace trailing_closure - } - - func interceptRequest(request: URLRequest) -> URLRequest { - return request - } + swizzler.unswizzle() + session.dataTask(with: url) { _, _, _ in }.resume() // not intercepted + session.dataTask(with: URLRequest(url: url)) { _, _, _ in }.resume() // not intercepted - func interceptTask(task: URLSessionTask) { + wait(for: [didInterceptCompletion], timeout: 5) } } diff --git a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskDelegateSwizzlerTests.swift b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskDelegateSwizzlerTests.swift index 73c5c8ee22..69839bd4bc 100644 --- a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskDelegateSwizzlerTests.swift +++ b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskDelegateSwizzlerTests.swift @@ -5,146 +5,104 @@ */ import XCTest -@testable import DatadogInternal - -final class URLSessionTaskDelegateSwizzlerTests: XCTestCase { - override func tearDown() { - URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate.self) - URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate1.self) - URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate2.self) - XCTAssertEqual(URLSessionTaskDelegateSwizzler.didFinishCollectingMap.count, 0) - - super.tearDown() - } - - func testSwizzling_whenMultipleDelegatesAreGiven() throws { - let delegate1 = MockDelegate1() - let didFinishCollecting1 = XCTestExpectation(description: "didFinishCollecting1") - try URLSessionTaskDelegateSwizzler.bind( - delegateClass: MockDelegate1.self, - interceptDidFinishCollecting: { _, _, _ in - didFinishCollecting1.fulfill() - }, interceptDidCompleteWithError: { _, _, _ in - didFinishCollecting1.fulfill() - } - ) - - let delegate2 = MockDelegate2() - let didFinishCollecting2 = XCTestExpectation(description: "didFinishCollecting2") - try URLSessionTaskDelegateSwizzler.bind( - delegateClass: MockDelegate2.self, - interceptDidFinishCollecting: { _, _, _ in - didFinishCollecting2.fulfill() - }, interceptDidCompleteWithError: { _, _, _ in - didFinishCollecting2.fulfill() - } - ) - - let session = URLSession(configuration: .default, delegate: delegate1, delegateQueue: nil) - let task1 = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - - let session2 = URLSession(configuration: .default, delegate: delegate2, delegateQueue: nil) - let task2 = session2.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - - task1.resume() - task2.resume() - wait(for: [didFinishCollecting1, didFinishCollecting2], timeout: 5) - } +@testable import DatadogInternal - func testSwizzling_whenMethodsAreImplemented() throws { +class URLSessionTaskDelegateSwizzlerTests: XCTestCase { + func testSwizzling_implementedMethods() throws { class MockDelegate: NSObject, URLSessionTaskDelegate { - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { } } let delegate = MockDelegate() - let didFinishCollecting = XCTestExpectation(description: "didFinishCollecting") + let didFinishCollecting = expectation(description: "didFinishCollecting") + let didCompleteWithError = expectation(description: "didCompleteWithError") - try URLSessionTaskDelegateSwizzler.bind( + // Given + let swizzler = URLSessionTaskDelegateSwizzler() + + try swizzler.swizzle( delegateClass: MockDelegate.self, interceptDidFinishCollecting: { _, _, _ in didFinishCollecting.fulfill() - }, interceptDidCompleteWithError: { _, _, _ in - didFinishCollecting.fulfill() + }, + interceptDidCompleteWithError: { _, _, _ in + didCompleteWithError.fulfill() } ) + // When let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - let task = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - task.resume() + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // intercepted - wait(for: [didFinishCollecting], timeout: 5) + wait(for: [didFinishCollecting, didCompleteWithError], timeout: 5) } - func testSwizzling_whenMethodsAreNotImplemented() throws { - class MockDelegate: NSObject, URLSessionTaskDelegate { + func testSwizzling_whenMethodsNotImplemented() throws { + class MockDelegate: NSObject, URLSessionDataDelegate { } let delegate = MockDelegate() - let didFinishCollecting = XCTestExpectation(description: "didFinishCollecting") + let didFinishCollecting = expectation(description: "didFinishCollecting") + let didCompleteWithError = expectation(description: "didCompleteWithError") + + // Given + let swizzler = URLSessionTaskDelegateSwizzler() - try URLSessionTaskDelegateSwizzler.bind( + try swizzler.swizzle( delegateClass: MockDelegate.self, interceptDidFinishCollecting: { _, _, _ in didFinishCollecting.fulfill() - }, interceptDidCompleteWithError: { _, _, _ in - didFinishCollecting.fulfill() + }, + interceptDidCompleteWithError: { _, _, _ in + didCompleteWithError.fulfill() } ) + // When let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) - let task = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - task.resume() + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // intercepted - wait(for: [didFinishCollecting], timeout: 5) + wait(for: [didFinishCollecting, didCompleteWithError], timeout: 5) } - func testBindings() throws { - XCTAssertNil(URLSessionTaskDelegateSwizzler.didFinishCollectingMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - - try URLSessionTaskDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidFinishCollecting: interceptDidFinishCollecting, interceptDidCompleteWithError: interceptDidCompleteWithError) - XCTAssertNotNil(URLSessionTaskDelegateSwizzler.didFinishCollectingMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - XCTAssertNotNil(URLSessionTaskDelegateSwizzler.didCompleteWithErrorMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) + func testUnSwizzling() throws { + class MockDelegate: NSObject, URLSessionDataDelegate { + } - try URLSessionTaskDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidFinishCollecting: interceptDidFinishCollecting, interceptDidCompleteWithError: interceptDidCompleteWithError) - XCTAssertNotNil(URLSessionTaskDelegateSwizzler.didFinishCollectingMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - XCTAssertNotNil(URLSessionTaskDelegateSwizzler.didCompleteWithErrorMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) + let delegate = MockDelegate() + let expectation = self.expectation(description: "not expected") + expectation.isInverted = true - URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate.self) - XCTAssertNil(URLSessionTaskDelegateSwizzler.didFinishCollectingMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - XCTAssertNil(URLSessionTaskDelegateSwizzler.didCompleteWithErrorMap[MetaTypeExtensions.key(from: MockDelegate.self)] as Any?) - } + // Given + let swizzler = URLSessionTaskDelegateSwizzler() - func testConcurrentBinding() throws { - // swiftlint:disable opening_brace trailing_closure - callConcurrently( - closures: [ - { try? URLSessionTaskDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidFinishCollecting: self.intercept(session:task:metrics:), interceptDidCompleteWithError: self.interceptDidCompleteWithError(session:task:error:)) }, - { URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate.self) }, - { try? URLSessionTaskDelegateSwizzler.bind(delegateClass: MockDelegate.self, interceptDidFinishCollecting: self.intercept(session:task:metrics:), interceptDidCompleteWithError: self.interceptDidCompleteWithError(session:task:error:)) }, - { URLSessionTaskDelegateSwizzler.unbind(delegateClass: MockDelegate.self) }, - ], - iterations: 50 + try swizzler.swizzle( + delegateClass: MockDelegate.self, + interceptDidFinishCollecting: { _, _, _ in + expectation.fulfill() + }, + interceptDidCompleteWithError: { _, _, _ in + expectation.fulfill() + } ) - // swiftlint:enable opening_brace trailing_closure - } - - func intercept(session: URLSession, task: URLSessionTask, metrics: URLSessionTaskMetrics) { - } - - func interceptDidFinishCollecting(session: URLSession, task: URLSessionTask, metrics: URLSessionTaskMetrics) { - } - func interceptDidCompleteWithError(session: URLSession, task: URLSessionTask, error: Error?) { - } - - class MockDelegate: NSObject, URLSessionTaskDelegate { - } + // When + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // not intercepted - class MockDelegate1: NSObject, URLSessionTaskDelegate { - } + swizzler.unswizzle() - class MockDelegate2: NSObject, URLSessionTaskDelegate { + waitForExpectations(timeout: 5) } } diff --git a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskSwizzlerTests.swift b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskSwizzlerTests.swift index 2728648207..dc8f20b952 100644 --- a/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskSwizzlerTests.swift +++ b/DatadogInternal/Tests/NetworkInstrumentation/URLSessionTaskSwizzlerTests.swift @@ -5,56 +5,36 @@ */ import XCTest -@testable import DatadogInternal - -final class URLSessionTaskSwizzlerTests: XCTestCase { - override func tearDown() { - URLSessionTaskSwizzler.unbind() - XCTAssertNil(URLSessionTaskSwizzler.resume as Any?) - - super.tearDown() - } - func testSwizzling() throws { - let expectation = XCTestExpectation(description: "resume") - try URLSessionTaskSwizzler.bind { _ in - expectation.fulfill() - } +@testable import DatadogInternal - let session = URLSession(configuration: .default) - let task = session.dataTask(with: URL(string: "https://www.datadoghq.com/")!) - task.resume() +class URLSessionTaskSwizzlerTests: XCTestCase { + func testSwizzling_taskResume() throws { + let expectation = self.expectation(description: "resume") - wait(for: [expectation], timeout: 5) - } + // Given + let swizzler = URLSessionTaskSwizzler() - func testBindings() { - XCTAssertNil(URLSessionTaskSwizzler.resume as Any?) - - try? URLSessionTaskSwizzler.bind(interceptResume: { _ in }) - XCTAssertNotNil(URLSessionTaskSwizzler.resume as Any?) + try swizzler.swizzle( + interceptResume: { _ in + expectation.fulfill() + } + ) - try? URLSessionTaskSwizzler.bind(interceptResume: { _ in }) - XCTAssertNotNil(URLSessionTaskSwizzler.resume as Any?) + // When + let session = URLSession(configuration: .ephemeral) + let url = URL(string: "https://www.datadoghq.com/")! + session + .dataTask(with: url) + .resume() // intercepted - URLSessionTaskSwizzler.unbind() - XCTAssertNil(URLSessionTaskSwizzler.resume as Any?) - } + swizzler.unswizzle() - func testConcurrentBinding() throws { - // swiftlint:disable opening_brace trailing_closure - callConcurrently( - closures: [ - { try? URLSessionTaskSwizzler.bind(interceptResume: self.intercept(task:)) }, - { URLSessionTaskSwizzler.unbind() }, - { try? URLSessionTaskSwizzler.bind(interceptResume: self.intercept(task:)) }, - { URLSessionTaskSwizzler.unbind() }, - ], - iterations: 50 - ) - // swiftlint:enable opening_brace trailing_closure - } + session + .dataTask(with: url) + .resume() // not intercepted - func intercept(task: URLSessionTask) { + // Then + wait(for: [expectation], timeout: 5) } } diff --git a/DatadogInternal/Tests/Swizzling/MethodSwizzlerTests.swift b/DatadogInternal/Tests/Swizzling/MethodSwizzlerTests.swift index 93675d0619..42756df053 100644 --- a/DatadogInternal/Tests/Swizzling/MethodSwizzlerTests.swift +++ b/DatadogInternal/Tests/Swizzling/MethodSwizzlerTests.swift @@ -7,120 +7,188 @@ import XCTest @testable import DatadogInternal -@objc -private class EmptySubclass: BaseClass { } - @objc private class BaseClass: NSObject { - @objc static let returnValue = "this is base class" - @objc func methodToSwizzle() -> String { - return Self.returnValue + "original" } } -class MethodSwizzlerTests: XCTestCase { - private typealias TypedIMPReturnString = @convention(c) (AnyObject, Selector) -> String - private typealias TypedBlockIMPReturnString = @convention(block) (AnyObject) -> String +private class Swizzler: MethodSwizzler<@convention(c) (AnyObject, Selector) -> String, @convention(block) (AnyObject) -> String> { + static let selector = #selector(BaseClass.methodToSwizzle) - private let selToSwizzle = #selector(BaseClass.methodToSwizzle) - private let newIMPReturnString: TypedBlockIMPReturnString = { _ in String.mockAny() } + let method: Method - private typealias Swizzler = MethodSwizzler - private let swizzler = Swizzler() + init(method: Method) { + self.method = method + } - override func tearDown() { - super.tearDown() - swizzler.unswizzle() + init(_ cls: BaseClass.Type = BaseClass.self, _ name: Selector = Swizzler.selector) throws { + method = try dd_class_getInstanceMethod(cls, name) + } + + func swizzle(callback: @escaping () -> Void) { + self.swizzle(method) { currentImp in + return { impSelf in + callback() + return currentImp(impSelf, Swizzler.selector) + } + } + } + + func swizzle(override: @escaping (String) -> String) { + self.swizzle(method) { currentImp in + return { impSelf in + return override(currentImp(impSelf, Swizzler.selector)) + } + } } +} +class MethodSwizzlerTests: XCTestCase { func test_simpleSwizzle() throws { + let swizzler = try Swizzler() let obj = BaseClass() // before - XCTAssertNotEqual(obj.perform(selToSwizzle)?.takeUnretainedValue() as? String, String.mockAny()) + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original") + // swizzle - let foundMethod = try Swizzler.findMethod(with: selToSwizzle, in: BaseClass.self) - swizzler.swizzle(foundMethod) { currentImp -> TypedBlockIMPReturnString in - return { impSelf in - return currentImp(impSelf, self.selToSwizzle).appending(String.mockAny()) - } - } + swizzler.swizzle { $0 + .mockAny() } + // after - XCTAssertEqual(obj.perform(selToSwizzle)?.takeUnretainedValue() as? String, BaseClass.returnValue + String.mockAny()) + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original" + .mockAny()) + swizzler.unswizzle() } func test_searchWrongSelector() { let wrongSelToSwizzle = Selector(("selector_who_never_existed")) let expectedErrorDescription = "\(NSStringFromSelector(wrongSelToSwizzle)) is not found in \(NSStringFromClass(BaseClass.self))" - XCTAssertThrowsError(try Swizzler.findMethod(with: wrongSelToSwizzle, in: BaseClass.self), "Wrong selector should throw") { error in + XCTAssertThrowsError(try dd_class_getInstanceMethod(BaseClass.self, wrongSelToSwizzle), "Wrong selector should throw") { error in let internalError = error as? InternalError XCTAssertEqual(internalError?.description, expectedErrorDescription) } } - func test_swizzle_alreadySwizzledSelector() throws { - let foundMethod = try Swizzler.findMethod(with: selToSwizzle, in: BaseClass.self) + func test_findSubclassMethod() throws { + class EmptySubclass: BaseClass { } + class EmptySubSubclass: EmptySubclass { } + XCTAssertNotNil(try dd_class_getInstanceMethod(EmptySubclass.self, Swizzler.selector)) + XCTAssertNotNil(try dd_class_getInstanceMethod(EmptySubSubclass.self, Swizzler.selector)) + } + + func test_multiple_swizzle() throws { + let method = try dd_class_getInstanceMethod(BaseClass.self, Swizzler.selector) + let swizzler1 = Swizzler(method: method) + let swizzler2 = Swizzler(method: method) + + let obj = BaseClass() + let before_imp = method_getImplementation(method) - let beforeOrigTypedIMP = swizzler.originalImplementation(of: foundMethod) // first swizzling - let firstAppendedReturnValue = "first" - swizzler.swizzle(foundMethod) { currentImp -> TypedBlockIMPReturnString in - return { impSelf in - return currentImp(impSelf, self.selToSwizzle).appending(firstAppendedReturnValue) - } - } + swizzler1.swizzle { $0 + ", first" } + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original, first") - let secondAppendedReturnValue = "second" - swizzler.swizzle(foundMethod) { currentImp -> TypedBlockIMPReturnString in - return { impSelf in - return currentImp(impSelf, self.selToSwizzle).appending(secondAppendedReturnValue) - } - } + // second swizzling + swizzler2.swizzle { $0 + ", second" } + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original, first, second") - let afterOrigTypedIMP = swizzler.originalImplementation(of: foundMethod) + // third swizzling + swizzler1.swizzle { $0 + ", third" } + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original, first, second, third") - let obj = BaseClass() - let expectedReturnValue = BaseClass.returnValue + firstAppendedReturnValue + secondAppendedReturnValue - XCTAssertEqual(obj.perform(selToSwizzle)?.takeUnretainedValue() as? String, expectedReturnValue) - XCTAssertEqual( - unsafeBitCast(beforeOrigTypedIMP, to: IMP.self), - unsafeBitCast(afterOrigTypedIMP, to: IMP.self) - ) + // remove second swizzling + swizzler2.unswizzle() + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original, first, third") + + // revert to original imp + swizzler1.unswizzle() + let after_imp = method_getImplementation(method) + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original") + XCTAssertEqual(before_imp, after_imp) } - func test_findSubclassMethod() throws { - let subclassMethod = try Swizzler.findMethod(with: selToSwizzle, in: EmptySubclass.self) + func test_swizzle_count() throws { + class Subclass: BaseClass { + override func methodToSwizzle() -> String { "subclass" } + } + class SubSubclass: Subclass { + override func methodToSwizzle() -> String { "subsubclass" } + } - XCTAssertNotNil(subclassMethod) - XCTAssertEqual(NSStringFromClass(subclassMethod.klass), NSStringFromClass(BaseClass.self)) + // Given + let method1 = try dd_class_getInstanceMethod(BaseClass.self, Swizzler.selector) + let method2 = try dd_class_getInstanceMethod(Subclass.self, Swizzler.selector) + let method3 = try dd_class_getInstanceMethod(SubSubclass.self, Swizzler.selector) + + let swizzler1 = Swizzler(method: method1) + let swizzler2 = Swizzler(method: method2) + let swizzler3 = Swizzler(method: method3) + + // When + swizzler1.swizzle { } + XCTAssertEqual(Swizzling.methods.count, 1) + swizzler2.swizzle { } + XCTAssertEqual(Swizzling.methods.count, 2) + swizzler3.swizzle { } + XCTAssertEqual(Swizzling.methods.count, 3) + + // Then + XCTAssertEqual(Swizzling.description, "[methodToSwizzle, methodToSwizzle, methodToSwizzle]") + + // When + swizzler1.unswizzle() + XCTAssertEqual(Swizzling.methods.count, 2) + swizzler2.unswizzle() + XCTAssertEqual(Swizzling.methods.count, 1) + swizzler3.unswizzle() + XCTAssertEqual(Swizzling.methods.count, 0) + + // Then + XCTAssertEqual(Swizzling.description, "[]") } - func test_originalIMP_immutability() throws { - let foundMethod = try Swizzler.findMethod(with: selToSwizzle, in: BaseClass.self) + func test_swizzle_concurrently() throws { + // swiftlint:disable opening_brace - // first swizzling - swizzler.swizzle(foundMethod) { _ -> TypedBlockIMPReturnString in - return { _ in - "first" - } - } - // second swizzling - swizzler.swizzle(foundMethod) { _ -> TypedBlockIMPReturnString in - return { _ in - "second" - } - } + // Given + let method = try dd_class_getInstanceMethod(BaseClass.self, Swizzler.selector) + let swizzler1 = Swizzler(method: method) + let swizzler2 = Swizzler(method: method) + let swizzler3 = Swizzler(method: method) - // revert to original imp - let originalTypedImp = swizzler.originalImplementation(of: foundMethod) - let originalImp = unsafeBitCast(originalTypedImp, to: IMP.self) - method_setImplementation(foundMethod.method, originalImp) + let before_imp = method_getImplementation(method) + var callstack: [String] = [] + + // When + callConcurrently( + { swizzler1.swizzle { callstack.append("1.1") } }, + { swizzler1.swizzle { callstack.append("1.2") } }, + { swizzler2.swizzle { callstack.append("2") } }, + { swizzler3.swizzle { callstack.append("3") } } + ) + // Then let obj = BaseClass() - let expectedReturnValue = BaseClass.returnValue - XCTAssertEqual(obj.perform(selToSwizzle)?.takeUnretainedValue() as? String, expectedReturnValue) + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original") + + callstack.sort() + XCTAssertEqual(callstack, ["1.1", "1.2", "2", "3"]) + + // When + callstack = [] + callConcurrently( + { swizzler1.unswizzle() }, + { swizzler2.unswizzle() }, + { swizzler3.unswizzle() } + ) + XCTAssertEqual(obj.perform(Swizzler.selector)?.takeUnretainedValue() as? String, "original") + XCTAssertEqual(callstack, []) + + let after_imp = method_getImplementation(method) + XCTAssertEqual(before_imp, after_imp) + // swiftlint:enable opening_brace } } diff --git a/DatadogInternal/Tests/Telemetry/TelemetryMocks.swift b/DatadogInternal/Tests/Telemetry/TelemetryMocks.swift index 529917b7c4..dee147c04a 100644 --- a/DatadogInternal/Tests/Telemetry/TelemetryMocks.swift +++ b/DatadogInternal/Tests/Telemetry/TelemetryMocks.swift @@ -14,6 +14,8 @@ extension ConfigurationTelemetry { actionNameAttribute: .mockRandom(), allowFallbackToLocalStorage: .mockRandom(), allowUntrustedEvents: .mockRandom(), + backgroundTasksEnabled: .mockRandom(), + batchProcessingLevel: .mockRandom(), batchSize: .mockRandom(), batchUploadFrequency: .mockRandom(), dartVersion: .mockRandom(), diff --git a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift index b877381113..eab8b085b4 100644 --- a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift +++ b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift @@ -201,6 +201,8 @@ class TelemetryTest: Telemetry { actionNameAttribute: configuration.actionNameAttribute, allowFallbackToLocalStorage: configuration.allowFallbackToLocalStorage, allowUntrustedEvents: configuration.allowUntrustedEvents, + backgroundTasksEnabled: configuration.backgroundTasksEnabled, + batchProcessingLevel: configuration.batchProcessingLevel, batchSize: configuration.batchSize, batchUploadFrequency: configuration.batchUploadFrequency, dartVersion: configuration.dartVersion, diff --git a/DatadogInternal/Tests/Utils/MetaTypeExtensionsTests.swift b/DatadogInternal/Tests/Utils/MetaTypeExtensionsTests.swift deleted file mode 100644 index de9c473ba8..0000000000 --- a/DatadogInternal/Tests/Utils/MetaTypeExtensionsTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-2020 Datadog, Inc. - */ - -import XCTest -@testable import DatadogInternal - -final class MetaTypeExtensionsTests: XCTestCase { - func testKey() throws { - // Prefix depends on the scheme which may be iOS or tvOS, hence only assert suffix. - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelStruct.self).hasSuffix(".TopLevelStruct")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelStruct.NestedStructInStruct.self).hasSuffix(".TopLevelStruct.NestedStructInStruct")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelStruct.NestedClassInStruct.self).hasSuffix(".TopLevelStruct.NestedClassInStruct")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelStruct.NestedEnumInStruct.self).hasSuffix(".TopLevelStruct.NestedEnumInStruct")) - - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelClass.self).hasSuffix(".TopLevelClass")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelClass.NestedStructInClass.self).hasSuffix(".TopLevelClass.NestedStructInClass")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelClass.NestedClassInClass.self).hasSuffix(".TopLevelClass.NestedClassInClass")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelClass.NestedEnumInClass.self).hasSuffix(".TopLevelClass.NestedEnumInClass")) - - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelEnum.self).hasSuffix(".TopLevelEnum")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelEnum.NestedStructInEnum.self).hasSuffix(".TopLevelEnum.NestedStructInEnum")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelEnum.NestedClassInEnum.self).hasSuffix(".TopLevelEnum.NestedClassInEnum")) - XCTAssertTrue(MetaTypeExtensions.key(from: TopLevelEnum.NestedEnumInEnum.self).hasSuffix(".TopLevelEnum.NestedEnumInEnum")) - } -} - -struct TopLevelStruct { - struct NestedStructInStruct {} - - class NestedClassInStruct {} - - enum NestedEnumInStruct {} -} - -class TopLevelClass { - struct NestedStructInClass {} - - class NestedClassInClass {} - - enum NestedEnumInClass {} -} - -enum TopLevelEnum { - struct NestedStructInEnum {} - - class NestedClassInEnum {} - - enum NestedEnumInEnum {} -} diff --git a/DatadogLogs.podspec b/DatadogLogs.podspec index d424fae5db..c2855c542a 100644 --- a/DatadogLogs.podspec +++ b/DatadogLogs.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogLogs" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog Logs Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogLogs/Sources/Feature/MessageReceivers.swift b/DatadogLogs/Sources/Feature/MessageReceivers.swift index 8132f65181..f43ea389f3 100644 --- a/DatadogLogs/Sources/Feature/MessageReceivers.swift +++ b/DatadogLogs/Sources/Feature/MessageReceivers.swift @@ -208,6 +208,7 @@ internal struct CrashLogReceiver: FeatureMessageReceiver { threadName: nil, applicationVersion: context.version, applicationBuildNumber: context.buildNumber, + buildId: nil, dd: .init( device: .init(architecture: deviceInfo.architecture) ), diff --git a/DatadogLogs/Sources/Log/LogEventBuilder.swift b/DatadogLogs/Sources/Log/LogEventBuilder.swift index af89362a8b..0c47dbac2e 100644 --- a/DatadogLogs/Sources/Log/LogEventBuilder.swift +++ b/DatadogLogs/Sources/Log/LogEventBuilder.swift @@ -71,6 +71,7 @@ internal struct LogEventBuilder { threadName: threadName, applicationVersion: context.version, applicationBuildNumber: context.buildNumber, + buildId: context.buildId, dd: LogEvent.Dd( device: LogEvent.DeviceInfo(architecture: context.device.architecture) ), diff --git a/DatadogLogs/Sources/Log/LogEventEncoder.swift b/DatadogLogs/Sources/Log/LogEventEncoder.swift index e9cd03e2c4..95dc03b3fd 100644 --- a/DatadogLogs/Sources/Log/LogEventEncoder.swift +++ b/DatadogLogs/Sources/Log/LogEventEncoder.swift @@ -95,6 +95,8 @@ public struct LogEvent: Encodable { public let applicationVersion: String /// The current application build number. public let applicationBuildNumber: String + /// The id of the current build (used for some cross platform frameworks) + public let buildId: String? /// Datadog specific attributes public let dd: Dd /// The associated log error @@ -138,6 +140,7 @@ internal struct LogEventEncoder { case applicationVersion = "version" case applicationBuildNumber = "build_version" + case buildId = "build_id" // MARK: - Dd info case dd = "_dd" @@ -203,6 +206,9 @@ internal struct LogEventEncoder { // Encode application info try container.encode(log.applicationVersion, forKey: .applicationVersion) try container.encode(log.applicationBuildNumber, forKey: .applicationBuildNumber) + if let buildId = log.buildId { + try container.encode(buildId, forKey: .buildId) + } try container.encode(log.dd, forKey: .dd) diff --git a/DatadogLogs/Sources/Log/LogEventSanitizer.swift b/DatadogLogs/Sources/Log/LogEventSanitizer.swift index 51ba3889da..58f89aedf8 100644 --- a/DatadogLogs/Sources/Log/LogEventSanitizer.swift +++ b/DatadogLogs/Sources/Log/LogEventSanitizer.swift @@ -16,6 +16,7 @@ internal struct LogEventSanitizer { "host", "message", "status", "service", "source", "ddtags", "dd.trace_id", "dd.span_id", "application_id", "session_id", "view.id", "user_action.id", + "build_id", ] /// Allowed first character of a tag name (given as ASCII values ranging from lowercased `a` to `z`) . /// Tags with name starting with different character will be dropped. diff --git a/DatadogLogs/Sources/Logs.swift b/DatadogLogs/Sources/Logs.swift index 498f1baaeb..1127714422 100644 --- a/DatadogLogs/Sources/Logs.swift +++ b/DatadogLogs/Sources/Logs.swift @@ -14,7 +14,7 @@ import DatadogInternal /// - Use default and add custom attributes to each log sent. /// - Record real client IP addresses and User-Agents. /// - Leverage optimized network usage with automatic bulk posts. -public struct Logs { +public enum Logs { /// The Logs general configuration. /// /// This configuration will be applied to all Logger instances. diff --git a/DatadogLogs/Tests/Log/LogEventBuilderTests.swift b/DatadogLogs/Tests/Log/LogEventBuilderTests.swift index 7291706d7d..adb2d586bc 100644 --- a/DatadogLogs/Tests/Log/LogEventBuilderTests.swift +++ b/DatadogLogs/Tests/Log/LogEventBuilderTests.swift @@ -132,6 +132,7 @@ class LogEventBuilderTests: XCTestCase { XCTAssertEqual(log.applicationVersion, randomSDKContext.version) XCTAssertEqual(log.applicationBuildNumber, randomSDKContext.buildNumber) XCTAssertEqual(log.loggerVersion, randomSDKContext.sdkVersion) + XCTAssertNil(log.buildId) XCTAssertEqual(log.userInfo.id, randomUserInfo.id) XCTAssertEqual(log.userInfo.name, randomUserInfo.name) XCTAssertEqual(log.userInfo.email, randomUserInfo.email) @@ -146,6 +147,35 @@ class LogEventBuilderTests: XCTestCase { wait(for: [expectation], timeout: 0) } + func testGivenContextWithBuildID_whenBuildingLog_itSetsBuildId() throws { + // Given + let buildId: String = .mockRandom() + let randomSDKContext: DatadogContext = .mockWith( + buildId: buildId + ) + + // When + let builder = LogEventBuilder( + service: .mockAny(), + loggerName: .mockAny(), + networkInfoEnabled: true, + eventMapper: nil + ) + + builder.createLogEvent( + date: .mockRandom(), + level: .mockAny(), + message: .mockAny(), + error: .mockAny(), + attributes: .mockAny(), + tags: .mockAny(), + context: randomSDKContext, + threadName: .mockAny() + ) { log in + XCTAssertEqual(log.buildId, buildId) + } + } + func testGivenSendNetworkInfoDisabled_whenBuildingLog_itDoesNotSetConnectionAndCarrierInfo() throws { let expectation = expectation(description: "build log event") diff --git a/DatadogLogs/Tests/Log/LogSanitizerTests.swift b/DatadogLogs/Tests/Log/LogSanitizerTests.swift index b51c360cfd..16e5d28b46 100644 --- a/DatadogLogs/Tests/Log/LogSanitizerTests.swift +++ b/DatadogLogs/Tests/Log/LogSanitizerTests.swift @@ -42,6 +42,7 @@ class LogSanitizerTests: XCTestCase { "message": mockValue(), "status": mockValue(), "service": mockValue(), + "build_id": mockValue(), "source": mockValue(), "ddtags": mockValue(), diff --git a/DatadogLogs/Tests/Mocks/LoggingFeatureMocks.swift b/DatadogLogs/Tests/Mocks/LoggingFeatureMocks.swift index f71509632b..ccd49cad88 100644 --- a/DatadogLogs/Tests/Mocks/LoggingFeatureMocks.swift +++ b/DatadogLogs/Tests/Mocks/LoggingFeatureMocks.swift @@ -117,6 +117,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: String = .mockAny(), applicationVersion: String = .mockAny(), applicationBuildNumber: String = .mockAny(), + buildId: String? = .mockAny(), dd: LogEvent.Dd = .mockAny(), os: LogEvent.OperatingSystem = .mockAny(), userInfo: UserInfo = .mockAny(), @@ -137,6 +138,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: threadName, applicationVersion: applicationVersion, applicationBuildNumber: applicationBuildNumber, + buildId: nil, dd: dd, os: os, userInfo: userInfo, @@ -160,6 +162,7 @@ extension LogEvent: AnyMockable, RandomMockable { threadName: .mockRandom(), applicationVersion: .mockRandom(), applicationBuildNumber: .mockRandom(), + buildId: .mockRandom(), dd: .mockRandom(), os: .mockRandom(), userInfo: .mockRandom(), diff --git a/DatadogObjc.podspec b/DatadogObjc.podspec index 8e770d8d94..09dc63740a 100644 --- a/DatadogObjc.podspec +++ b/DatadogObjc.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogObjc" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Objective-C SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogObjc/Sources/DatadogConfiguration+objc.swift b/DatadogObjc/Sources/DatadogConfiguration+objc.swift index a94ab41c3f..9a9cd0b605 100644 --- a/DatadogObjc/Sources/DatadogConfiguration+objc.swift +++ b/DatadogObjc/Sources/DatadogConfiguration+objc.swift @@ -83,6 +83,29 @@ public enum DDUploadFrequency: Int { } } +@objc +public enum DDBatchProcessingLevel: Int { + case low + case medium + case high + + internal var swiftType: Datadog.Configuration.BatchProcessingLevel { + switch self { + case .low: return .low + case .medium: return .medium + case .high: return .high + } + } + + internal init(swiftType: Datadog.Configuration.BatchProcessingLevel) { + switch swiftType { + case .low: self = .low + case .medium: self = .medium + case .high: self = .high + } + } +} + @objc public class DDTracingHeaderType: NSObject { internal let swiftType: TracingHeaderType @@ -196,6 +219,12 @@ public class DDConfiguration: NSObject { set { sdkConfiguration.uploadFrequency = newValue.swiftType } } + /// + @objc public var batchProcessingLevel: DDBatchProcessingLevel { + get { DDBatchProcessingLevel(swiftType: sdkConfiguration.batchProcessingLevel) } + set { sdkConfiguration.batchProcessingLevel = newValue.swiftType } + } + /// Proxy configuration attributes. /// This can be used to a enable a custom proxy for uploading tracked data to Datadog's intake. @objc public var proxyConfiguration: [AnyHashable: Any]? { diff --git a/DatadogObjc/Sources/RUM/RUMDataModels+objc.swift b/DatadogObjc/Sources/RUM/RUMDataModels+objc.swift index 3e167f3219..82e5ce2acb 100644 --- a/DatadogObjc/Sources/RUM/RUMDataModels+objc.swift +++ b/DatadogObjc/Sources/RUM/RUMDataModels+objc.swift @@ -32,6 +32,10 @@ public class DDRUMActionEvent: NSObject { DDRUMActionEventApplication(root: root) } + @objc public var buildId: String? { + root.swiftModel.buildId + } + @objc public var buildVersion: String? { root.swiftModel.buildVersion } @@ -44,6 +48,10 @@ public class DDRUMActionEvent: NSObject { root.swiftModel.connectivity != nil ? DDRUMActionEventRUMConnectivity(root: root) : nil } + @objc public var container: DDRUMActionEventContainer? { + root.swiftModel.container != nil ? DDRUMActionEventContainer(root: root) : nil + } + @objc public var context: DDRUMActionEventRUMEventAttributes? { root.swiftModel.context != nil ? DDRUMActionEventRUMEventAttributes(root: root) : nil } @@ -76,8 +84,8 @@ public class DDRUMActionEvent: NSObject { .init(swift: root.swiftModel.source) } - @objc public var synthetics: DDRUMActionEventSynthetics? { - root.swiftModel.synthetics != nil ? DDRUMActionEventSynthetics(root: root) : nil + @objc public var synthetics: DDRUMActionEventRUMSyntheticsTest? { + root.swiftModel.synthetics != nil ? DDRUMActionEventRUMSyntheticsTest(root: root) : nil } @objc public var type: String { @@ -209,6 +217,10 @@ public class DDRUMActionEventDDSession: NSObject { @objc public var plan: DDRUMActionEventDDSessionPlan { .init(swift: root.swiftModel.dd.session!.plan) } + + @objc public var sessionPrecondition: DDRUMActionEventDDSessionRUMSessionPrecondition { + .init(swift: root.swiftModel.dd.session!.sessionPrecondition) + } } @objc @@ -234,6 +246,44 @@ public enum DDRUMActionEventDDSessionPlan: Int { case plan2 } +@objc +public enum DDRUMActionEventDDSessionRUMSessionPrecondition: Int { + internal init(swift: RUMSessionPrecondition?) { + switch swift { + case nil: self = .none + case .userAppLaunch?: self = .userAppLaunch + case .inactivityTimeout?: self = .inactivityTimeout + case .maxDuration?: self = .maxDuration + case .backgroundLaunch?: self = .backgroundLaunch + case .prewarm?: self = .prewarm + case .fromNonInteractiveSession?: self = .fromNonInteractiveSession + case .explicitStop?: self = .explicitStop + } + } + + internal var toSwift: RUMSessionPrecondition? { + switch self { + case .none: return nil + case .userAppLaunch: return .userAppLaunch + case .inactivityTimeout: return .inactivityTimeout + case .maxDuration: return .maxDuration + case .backgroundLaunch: return .backgroundLaunch + case .prewarm: return .prewarm + case .fromNonInteractiveSession: return .fromNonInteractiveSession + case .explicitStop: return .explicitStop + } + } + + case none + case userAppLaunch + case inactivityTimeout + case maxDuration + case backgroundLaunch + case prewarm + case fromNonInteractiveSession + case explicitStop +} + @objc public class DDRUMActionEventAction: NSObject { internal let root: DDRUMActionEvent @@ -550,6 +600,68 @@ public enum DDRUMActionEventRUMConnectivityStatus: Int { case maybe } +@objc +public class DDRUMActionEventContainer: NSObject { + internal let root: DDRUMActionEvent + + internal init(root: DDRUMActionEvent) { + self.root = root + } + + @objc public var source: DDRUMActionEventContainerSource { + .init(swift: root.swiftModel.container!.source) + } + + @objc public var view: DDRUMActionEventContainerView { + DDRUMActionEventContainerView(root: root) + } +} + +@objc +public enum DDRUMActionEventContainerSource: Int { + internal init(swift: RUMActionEvent.Container.Source) { + switch swift { + case .android: self = .android + case .ios: self = .ios + case .browser: self = .browser + case .flutter: self = .flutter + case .reactNative: self = .reactNative + case .roku: self = .roku + } + } + + internal var toSwift: RUMActionEvent.Container.Source { + switch self { + case .android: return .android + case .ios: return .ios + case .browser: return .browser + case .flutter: return .flutter + case .reactNative: return .reactNative + case .roku: return .roku + } + } + + case android + case ios + case browser + case flutter + case reactNative + case roku +} + +@objc +public class DDRUMActionEventContainerView: NSObject { + internal let root: DDRUMActionEvent + + internal init(root: DDRUMActionEvent) { + self.root = root + } + + @objc public var id: String { + root.swiftModel.container!.view.id + } +} + @objc public class DDRUMActionEventRUMEventAttributes: NSObject { internal let root: DDRUMActionEvent @@ -698,14 +810,14 @@ public class DDRUMActionEventSession: NSObject { root.swiftModel.session.id } - @objc public var type: DDRUMActionEventSessionSessionType { + @objc public var type: DDRUMActionEventSessionRUMSessionType { .init(swift: root.swiftModel.session.type) } } @objc -public enum DDRUMActionEventSessionSessionType: Int { - internal init(swift: RUMActionEvent.Session.SessionType) { +public enum DDRUMActionEventSessionRUMSessionType: Int { + internal init(swift: RUMSessionType) { switch swift { case .user: self = .user case .synthetics: self = .synthetics @@ -713,7 +825,7 @@ public enum DDRUMActionEventSessionSessionType: Int { } } - internal var toSwift: RUMActionEvent.Session.SessionType { + internal var toSwift: RUMSessionType { switch self { case .user: return .user case .synthetics: return .synthetics @@ -762,7 +874,7 @@ public enum DDRUMActionEventSource: Int { } @objc -public class DDRUMActionEventSynthetics: NSObject { +public class DDRUMActionEventRUMSyntheticsTest: NSObject { internal let root: DDRUMActionEvent internal init(root: DDRUMActionEvent) { @@ -860,6 +972,10 @@ public class DDRUMErrorEvent: NSObject { DDRUMErrorEventApplication(root: root) } + @objc public var buildId: String? { + root.swiftModel.buildId + } + @objc public var buildVersion: String? { root.swiftModel.buildVersion } @@ -872,6 +988,10 @@ public class DDRUMErrorEvent: NSObject { root.swiftModel.connectivity != nil ? DDRUMErrorEventRUMConnectivity(root: root) : nil } + @objc public var container: DDRUMErrorEventContainer? { + root.swiftModel.container != nil ? DDRUMErrorEventContainer(root: root) : nil + } + @objc public var context: DDRUMErrorEventRUMEventAttributes? { root.swiftModel.context != nil ? DDRUMErrorEventRUMEventAttributes(root: root) : nil } @@ -912,8 +1032,8 @@ public class DDRUMErrorEvent: NSObject { .init(swift: root.swiftModel.source) } - @objc public var synthetics: DDRUMErrorEventSynthetics? { - root.swiftModel.synthetics != nil ? DDRUMErrorEventSynthetics(root: root) : nil + @objc public var synthetics: DDRUMErrorEventRUMSyntheticsTest? { + root.swiftModel.synthetics != nil ? DDRUMErrorEventRUMSyntheticsTest(root: root) : nil } @objc public var type: String { @@ -986,6 +1106,10 @@ public class DDRUMErrorEventDDSession: NSObject { @objc public var plan: DDRUMErrorEventDDSessionPlan { .init(swift: root.swiftModel.dd.session!.plan) } + + @objc public var sessionPrecondition: DDRUMErrorEventDDSessionRUMSessionPrecondition { + .init(swift: root.swiftModel.dd.session!.sessionPrecondition) + } } @objc @@ -1011,6 +1135,44 @@ public enum DDRUMErrorEventDDSessionPlan: Int { case plan2 } +@objc +public enum DDRUMErrorEventDDSessionRUMSessionPrecondition: Int { + internal init(swift: RUMSessionPrecondition?) { + switch swift { + case nil: self = .none + case .userAppLaunch?: self = .userAppLaunch + case .inactivityTimeout?: self = .inactivityTimeout + case .maxDuration?: self = .maxDuration + case .backgroundLaunch?: self = .backgroundLaunch + case .prewarm?: self = .prewarm + case .fromNonInteractiveSession?: self = .fromNonInteractiveSession + case .explicitStop?: self = .explicitStop + } + } + + internal var toSwift: RUMSessionPrecondition? { + switch self { + case .none: return nil + case .userAppLaunch: return .userAppLaunch + case .inactivityTimeout: return .inactivityTimeout + case .maxDuration: return .maxDuration + case .backgroundLaunch: return .backgroundLaunch + case .prewarm: return .prewarm + case .fromNonInteractiveSession: return .fromNonInteractiveSession + case .explicitStop: return .explicitStop + } + } + + case none + case userAppLaunch + case inactivityTimeout + case maxDuration + case backgroundLaunch + case prewarm + case fromNonInteractiveSession + case explicitStop +} + @objc public class DDRUMErrorEventAction: NSObject { internal let root: DDRUMErrorEvent @@ -1175,6 +1337,68 @@ public enum DDRUMErrorEventRUMConnectivityStatus: Int { case maybe } +@objc +public class DDRUMErrorEventContainer: NSObject { + internal let root: DDRUMErrorEvent + + internal init(root: DDRUMErrorEvent) { + self.root = root + } + + @objc public var source: DDRUMErrorEventContainerSource { + .init(swift: root.swiftModel.container!.source) + } + + @objc public var view: DDRUMErrorEventContainerView { + DDRUMErrorEventContainerView(root: root) + } +} + +@objc +public enum DDRUMErrorEventContainerSource: Int { + internal init(swift: RUMErrorEvent.Container.Source) { + switch swift { + case .android: self = .android + case .ios: self = .ios + case .browser: self = .browser + case .flutter: self = .flutter + case .reactNative: self = .reactNative + case .roku: self = .roku + } + } + + internal var toSwift: RUMErrorEvent.Container.Source { + switch self { + case .android: return .android + case .ios: return .ios + case .browser: return .browser + case .flutter: return .flutter + case .reactNative: return .reactNative + case .roku: return .roku + } + } + + case android + case ios + case browser + case flutter + case reactNative + case roku +} + +@objc +public class DDRUMErrorEventContainerView: NSObject { + internal let root: DDRUMErrorEvent + + internal init(root: DDRUMErrorEvent) { + self.root = root + } + + @objc public var id: String { + root.swiftModel.container!.view.id + } +} + @objc public class DDRUMErrorEventRUMEventAttributes: NSObject { internal let root: DDRUMErrorEvent @@ -1697,14 +1921,14 @@ public class DDRUMErrorEventSession: NSObject { root.swiftModel.session.id } - @objc public var type: DDRUMErrorEventSessionSessionType { + @objc public var type: DDRUMErrorEventSessionRUMSessionType { .init(swift: root.swiftModel.session.type) } } @objc -public enum DDRUMErrorEventSessionSessionType: Int { - internal init(swift: RUMErrorEvent.Session.SessionType) { +public enum DDRUMErrorEventSessionRUMSessionType: Int { + internal init(swift: RUMSessionType) { switch swift { case .user: self = .user case .synthetics: self = .synthetics @@ -1712,7 +1936,7 @@ public enum DDRUMErrorEventSessionSessionType: Int { } } - internal var toSwift: RUMErrorEvent.Session.SessionType { + internal var toSwift: RUMSessionType { switch self { case .user: return .user case .synthetics: return .synthetics @@ -1761,7 +1985,7 @@ public enum DDRUMErrorEventSource: Int { } @objc -public class DDRUMErrorEventSynthetics: NSObject { +public class DDRUMErrorEventRUMSyntheticsTest: NSObject { internal let root: DDRUMErrorEvent internal init(root: DDRUMErrorEvent) { @@ -1859,6 +2083,10 @@ public class DDRUMLongTaskEvent: NSObject { DDRUMLongTaskEventApplication(root: root) } + @objc public var buildId: String? { + root.swiftModel.buildId + } + @objc public var buildVersion: String? { root.swiftModel.buildVersion } @@ -1871,6 +2099,10 @@ public class DDRUMLongTaskEvent: NSObject { root.swiftModel.connectivity != nil ? DDRUMLongTaskEventRUMConnectivity(root: root) : nil } + @objc public var container: DDRUMLongTaskEventContainer? { + root.swiftModel.container != nil ? DDRUMLongTaskEventContainer(root: root) : nil + } + @objc public var context: DDRUMLongTaskEventRUMEventAttributes? { root.swiftModel.context != nil ? DDRUMLongTaskEventRUMEventAttributes(root: root) : nil } @@ -1907,8 +2139,8 @@ public class DDRUMLongTaskEvent: NSObject { .init(swift: root.swiftModel.source) } - @objc public var synthetics: DDRUMLongTaskEventSynthetics? { - root.swiftModel.synthetics != nil ? DDRUMLongTaskEventSynthetics(root: root) : nil + @objc public var synthetics: DDRUMLongTaskEventRUMSyntheticsTest? { + root.swiftModel.synthetics != nil ? DDRUMLongTaskEventRUMSyntheticsTest(root: root) : nil } @objc public var type: String { @@ -1985,6 +2217,10 @@ public class DDRUMLongTaskEventDDSession: NSObject { @objc public var plan: DDRUMLongTaskEventDDSessionPlan { .init(swift: root.swiftModel.dd.session!.plan) } + + @objc public var sessionPrecondition: DDRUMLongTaskEventDDSessionRUMSessionPrecondition { + .init(swift: root.swiftModel.dd.session!.sessionPrecondition) + } } @objc @@ -2010,6 +2246,44 @@ public enum DDRUMLongTaskEventDDSessionPlan: Int { case plan2 } +@objc +public enum DDRUMLongTaskEventDDSessionRUMSessionPrecondition: Int { + internal init(swift: RUMSessionPrecondition?) { + switch swift { + case nil: self = .none + case .userAppLaunch?: self = .userAppLaunch + case .inactivityTimeout?: self = .inactivityTimeout + case .maxDuration?: self = .maxDuration + case .backgroundLaunch?: self = .backgroundLaunch + case .prewarm?: self = .prewarm + case .fromNonInteractiveSession?: self = .fromNonInteractiveSession + case .explicitStop?: self = .explicitStop + } + } + + internal var toSwift: RUMSessionPrecondition? { + switch self { + case .none: return nil + case .userAppLaunch: return .userAppLaunch + case .inactivityTimeout: return .inactivityTimeout + case .maxDuration: return .maxDuration + case .backgroundLaunch: return .backgroundLaunch + case .prewarm: return .prewarm + case .fromNonInteractiveSession: return .fromNonInteractiveSession + case .explicitStop: return .explicitStop + } + } + + case none + case userAppLaunch + case inactivityTimeout + case maxDuration + case backgroundLaunch + case prewarm + case fromNonInteractiveSession + case explicitStop +} + @objc public class DDRUMLongTaskEventAction: NSObject { internal let root: DDRUMLongTaskEvent @@ -2174,6 +2448,68 @@ public enum DDRUMLongTaskEventRUMConnectivityStatus: Int { case maybe } +@objc +public class DDRUMLongTaskEventContainer: NSObject { + internal let root: DDRUMLongTaskEvent + + internal init(root: DDRUMLongTaskEvent) { + self.root = root + } + + @objc public var source: DDRUMLongTaskEventContainerSource { + .init(swift: root.swiftModel.container!.source) + } + + @objc public var view: DDRUMLongTaskEventContainerView { + DDRUMLongTaskEventContainerView(root: root) + } +} + +@objc +public enum DDRUMLongTaskEventContainerSource: Int { + internal init(swift: RUMLongTaskEvent.Container.Source) { + switch swift { + case .android: self = .android + case .ios: self = .ios + case .browser: self = .browser + case .flutter: self = .flutter + case .reactNative: self = .reactNative + case .roku: self = .roku + } + } + + internal var toSwift: RUMLongTaskEvent.Container.Source { + switch self { + case .android: return .android + case .ios: return .ios + case .browser: return .browser + case .flutter: return .flutter + case .reactNative: return .reactNative + case .roku: return .roku + } + } + + case android + case ios + case browser + case flutter + case reactNative + case roku +} + +@objc +public class DDRUMLongTaskEventContainerView: NSObject { + internal let root: DDRUMLongTaskEvent + + internal init(root: DDRUMLongTaskEvent) { + self.root = root + } + + @objc public var id: String { + root.swiftModel.container!.view.id + } +} + @objc public class DDRUMLongTaskEventRUMEventAttributes: NSObject { internal let root: DDRUMLongTaskEvent @@ -2343,14 +2679,14 @@ public class DDRUMLongTaskEventSession: NSObject { root.swiftModel.session.id } - @objc public var type: DDRUMLongTaskEventSessionSessionType { + @objc public var type: DDRUMLongTaskEventSessionRUMSessionType { .init(swift: root.swiftModel.session.type) } } @objc -public enum DDRUMLongTaskEventSessionSessionType: Int { - internal init(swift: RUMLongTaskEvent.Session.SessionType) { +public enum DDRUMLongTaskEventSessionRUMSessionType: Int { + internal init(swift: RUMSessionType) { switch swift { case .user: self = .user case .synthetics: self = .synthetics @@ -2358,7 +2694,7 @@ public enum DDRUMLongTaskEventSessionSessionType: Int { } } - internal var toSwift: RUMLongTaskEvent.Session.SessionType { + internal var toSwift: RUMSessionType { switch self { case .user: return .user case .synthetics: return .synthetics @@ -2407,7 +2743,7 @@ public enum DDRUMLongTaskEventSource: Int { } @objc -public class DDRUMLongTaskEventSynthetics: NSObject { +public class DDRUMLongTaskEventRUMSyntheticsTest: NSObject { internal let root: DDRUMLongTaskEvent internal init(root: DDRUMLongTaskEvent) { @@ -2501,6 +2837,10 @@ public class DDRUMResourceEvent: NSObject { DDRUMResourceEventApplication(root: root) } + @objc public var buildId: String? { + root.swiftModel.buildId + } + @objc public var buildVersion: String? { root.swiftModel.buildVersion } @@ -2513,6 +2853,10 @@ public class DDRUMResourceEvent: NSObject { root.swiftModel.connectivity != nil ? DDRUMResourceEventRUMConnectivity(root: root) : nil } + @objc public var container: DDRUMResourceEventContainer? { + root.swiftModel.container != nil ? DDRUMResourceEventContainer(root: root) : nil + } + @objc public var context: DDRUMResourceEventRUMEventAttributes? { root.swiftModel.context != nil ? DDRUMResourceEventRUMEventAttributes(root: root) : nil } @@ -2549,8 +2893,8 @@ public class DDRUMResourceEvent: NSObject { .init(swift: root.swiftModel.source) } - @objc public var synthetics: DDRUMResourceEventSynthetics? { - root.swiftModel.synthetics != nil ? DDRUMResourceEventSynthetics(root: root) : nil + @objc public var synthetics: DDRUMResourceEventRUMSyntheticsTest? { + root.swiftModel.synthetics != nil ? DDRUMResourceEventRUMSyntheticsTest(root: root) : nil } @objc public var type: String { @@ -2639,6 +2983,10 @@ public class DDRUMResourceEventDDSession: NSObject { @objc public var plan: DDRUMResourceEventDDSessionPlan { .init(swift: root.swiftModel.dd.session!.plan) } + + @objc public var sessionPrecondition: DDRUMResourceEventDDSessionRUMSessionPrecondition { + .init(swift: root.swiftModel.dd.session!.sessionPrecondition) + } } @objc @@ -2664,6 +3012,44 @@ public enum DDRUMResourceEventDDSessionPlan: Int { case plan2 } +@objc +public enum DDRUMResourceEventDDSessionRUMSessionPrecondition: Int { + internal init(swift: RUMSessionPrecondition?) { + switch swift { + case nil: self = .none + case .userAppLaunch?: self = .userAppLaunch + case .inactivityTimeout?: self = .inactivityTimeout + case .maxDuration?: self = .maxDuration + case .backgroundLaunch?: self = .backgroundLaunch + case .prewarm?: self = .prewarm + case .fromNonInteractiveSession?: self = .fromNonInteractiveSession + case .explicitStop?: self = .explicitStop + } + } + + internal var toSwift: RUMSessionPrecondition? { + switch self { + case .none: return nil + case .userAppLaunch: return .userAppLaunch + case .inactivityTimeout: return .inactivityTimeout + case .maxDuration: return .maxDuration + case .backgroundLaunch: return .backgroundLaunch + case .prewarm: return .prewarm + case .fromNonInteractiveSession: return .fromNonInteractiveSession + case .explicitStop: return .explicitStop + } + } + + case none + case userAppLaunch + case inactivityTimeout + case maxDuration + case backgroundLaunch + case prewarm + case fromNonInteractiveSession + case explicitStop +} + @objc public class DDRUMResourceEventAction: NSObject { internal let root: DDRUMResourceEvent @@ -2828,6 +3214,68 @@ public enum DDRUMResourceEventRUMConnectivityStatus: Int { case maybe } +@objc +public class DDRUMResourceEventContainer: NSObject { + internal let root: DDRUMResourceEvent + + internal init(root: DDRUMResourceEvent) { + self.root = root + } + + @objc public var source: DDRUMResourceEventContainerSource { + .init(swift: root.swiftModel.container!.source) + } + + @objc public var view: DDRUMResourceEventContainerView { + DDRUMResourceEventContainerView(root: root) + } +} + +@objc +public enum DDRUMResourceEventContainerSource: Int { + internal init(swift: RUMResourceEvent.Container.Source) { + switch swift { + case .android: self = .android + case .ios: self = .ios + case .browser: self = .browser + case .flutter: self = .flutter + case .reactNative: self = .reactNative + case .roku: self = .roku + } + } + + internal var toSwift: RUMResourceEvent.Container.Source { + switch self { + case .android: return .android + case .ios: return .ios + case .browser: return .browser + case .flutter: return .flutter + case .reactNative: return .reactNative + case .roku: return .roku + } + } + + case android + case ios + case browser + case flutter + case reactNative + case roku +} + +@objc +public class DDRUMResourceEventContainerView: NSObject { + internal let root: DDRUMResourceEvent + + internal init(root: DDRUMResourceEvent) { + self.root = root + } + + @objc public var id: String { + root.swiftModel.container!.view.id + } +} + @objc public class DDRUMResourceEventRUMEventAttributes: NSObject { internal let root: DDRUMResourceEvent @@ -3360,14 +3808,14 @@ public class DDRUMResourceEventSession: NSObject { root.swiftModel.session.id } - @objc public var type: DDRUMResourceEventSessionSessionType { + @objc public var type: DDRUMResourceEventSessionRUMSessionType { .init(swift: root.swiftModel.session.type) } } @objc -public enum DDRUMResourceEventSessionSessionType: Int { - internal init(swift: RUMResourceEvent.Session.SessionType) { +public enum DDRUMResourceEventSessionRUMSessionType: Int { + internal init(swift: RUMSessionType) { switch swift { case .user: self = .user case .synthetics: self = .synthetics @@ -3375,7 +3823,7 @@ public enum DDRUMResourceEventSessionSessionType: Int { } } - internal var toSwift: RUMResourceEvent.Session.SessionType { + internal var toSwift: RUMSessionType { switch self { case .user: return .user case .synthetics: return .synthetics @@ -3424,7 +3872,7 @@ public enum DDRUMResourceEventSource: Int { } @objc -public class DDRUMResourceEventSynthetics: NSObject { +public class DDRUMResourceEventRUMSyntheticsTest: NSObject { internal let root: DDRUMResourceEvent internal init(root: DDRUMResourceEvent) { @@ -3514,6 +3962,10 @@ public class DDRUMViewEvent: NSObject { DDRUMViewEventApplication(root: root) } + @objc public var buildId: String? { + root.swiftModel.buildId + } + @objc public var buildVersion: String? { root.swiftModel.buildVersion } @@ -3526,6 +3978,10 @@ public class DDRUMViewEvent: NSObject { root.swiftModel.connectivity != nil ? DDRUMViewEventRUMConnectivity(root: root) : nil } + @objc public var container: DDRUMViewEventContainer? { + root.swiftModel.container != nil ? DDRUMViewEventContainer(root: root) : nil + } + @objc public var context: DDRUMViewEventRUMEventAttributes? { root.swiftModel.context != nil ? DDRUMViewEventRUMEventAttributes(root: root) : nil } @@ -3566,8 +4022,8 @@ public class DDRUMViewEvent: NSObject { .init(swift: root.swiftModel.source) } - @objc public var synthetics: DDRUMViewEventSynthetics? { - root.swiftModel.synthetics != nil ? DDRUMViewEventSynthetics(root: root) : nil + @objc public var synthetics: DDRUMViewEventRUMSyntheticsTest? { + root.swiftModel.synthetics != nil ? DDRUMViewEventRUMSyntheticsTest(root: root) : nil } @objc public var type: String { @@ -3639,6 +4095,10 @@ public class DDRUMViewEventDDConfiguration: NSObject { @objc public var sessionSampleRate: NSNumber { root.swiftModel.dd.configuration!.sessionSampleRate as NSNumber } + + @objc public var startSessionReplayRecordingManually: NSNumber? { + root.swiftModel.dd.configuration!.startSessionReplayRecordingManually as NSNumber? + } } @objc @@ -3720,6 +4180,10 @@ public class DDRUMViewEventDDSession: NSObject { @objc public var plan: DDRUMViewEventDDSessionPlan { .init(swift: root.swiftModel.dd.session!.plan) } + + @objc public var sessionPrecondition: DDRUMViewEventDDSessionRUMSessionPrecondition { + .init(swift: root.swiftModel.dd.session!.sessionPrecondition) + } } @objc @@ -3745,6 +4209,44 @@ public enum DDRUMViewEventDDSessionPlan: Int { case plan2 } +@objc +public enum DDRUMViewEventDDSessionRUMSessionPrecondition: Int { + internal init(swift: RUMSessionPrecondition?) { + switch swift { + case nil: self = .none + case .userAppLaunch?: self = .userAppLaunch + case .inactivityTimeout?: self = .inactivityTimeout + case .maxDuration?: self = .maxDuration + case .backgroundLaunch?: self = .backgroundLaunch + case .prewarm?: self = .prewarm + case .fromNonInteractiveSession?: self = .fromNonInteractiveSession + case .explicitStop?: self = .explicitStop + } + } + + internal var toSwift: RUMSessionPrecondition? { + switch self { + case .none: return nil + case .userAppLaunch: return .userAppLaunch + case .inactivityTimeout: return .inactivityTimeout + case .maxDuration: return .maxDuration + case .backgroundLaunch: return .backgroundLaunch + case .prewarm: return .prewarm + case .fromNonInteractiveSession: return .fromNonInteractiveSession + case .explicitStop: return .explicitStop + } + } + + case none + case userAppLaunch + case inactivityTimeout + case maxDuration + case backgroundLaunch + case prewarm + case fromNonInteractiveSession + case explicitStop +} + @objc public class DDRUMViewEventApplication: NSObject { internal let root: DDRUMViewEvent @@ -3873,6 +4375,68 @@ public enum DDRUMViewEventRUMConnectivityStatus: Int { case maybe } +@objc +public class DDRUMViewEventContainer: NSObject { + internal let root: DDRUMViewEvent + + internal init(root: DDRUMViewEvent) { + self.root = root + } + + @objc public var source: DDRUMViewEventContainerSource { + .init(swift: root.swiftModel.container!.source) + } + + @objc public var view: DDRUMViewEventContainerView { + DDRUMViewEventContainerView(root: root) + } +} + +@objc +public enum DDRUMViewEventContainerSource: Int { + internal init(swift: RUMViewEvent.Container.Source) { + switch swift { + case .android: self = .android + case .ios: self = .ios + case .browser: self = .browser + case .flutter: self = .flutter + case .reactNative: self = .reactNative + case .roku: self = .roku + } + } + + internal var toSwift: RUMViewEvent.Container.Source { + switch self { + case .android: return .android + case .ios: return .ios + case .browser: return .browser + case .flutter: return .flutter + case .reactNative: return .reactNative + case .roku: return .roku + } + } + + case android + case ios + case browser + case flutter + case reactNative + case roku +} + +@objc +public class DDRUMViewEventContainerView: NSObject { + internal let root: DDRUMViewEvent + + internal init(root: DDRUMViewEvent) { + self.root = root + } + + @objc public var id: String { + root.swiftModel.container!.view.id + } +} + @objc public class DDRUMViewEventRUMEventAttributes: NSObject { internal let root: DDRUMViewEvent @@ -4107,50 +4671,14 @@ public class DDRUMViewEventSession: NSObject { root.swiftModel.session.sampledForReplay as NSNumber? } - @objc public var startPrecondition: DDRUMViewEventSessionStartPrecondition { - .init(swift: root.swiftModel.session.startPrecondition) - } - - @objc public var type: DDRUMViewEventSessionSessionType { + @objc public var type: DDRUMViewEventSessionRUMSessionType { .init(swift: root.swiftModel.session.type) } } @objc -public enum DDRUMViewEventSessionStartPrecondition: Int { - internal init(swift: RUMViewEvent.Session.StartPrecondition?) { - switch swift { - case nil: self = .none - case .appLaunch?: self = .appLaunch - case .inactivityTimeout?: self = .inactivityTimeout - case .maxDuration?: self = .maxDuration - case .explicitStop?: self = .explicitStop - case .backgroundEvent?: self = .backgroundEvent - } - } - - internal var toSwift: RUMViewEvent.Session.StartPrecondition? { - switch self { - case .none: return nil - case .appLaunch: return .appLaunch - case .inactivityTimeout: return .inactivityTimeout - case .maxDuration: return .maxDuration - case .explicitStop: return .explicitStop - case .backgroundEvent: return .backgroundEvent - } - } - - case none - case appLaunch - case inactivityTimeout - case maxDuration - case explicitStop - case backgroundEvent -} - -@objc -public enum DDRUMViewEventSessionSessionType: Int { - internal init(swift: RUMViewEvent.Session.SessionType) { +public enum DDRUMViewEventSessionRUMSessionType: Int { + internal init(swift: RUMSessionType) { switch swift { case .user: self = .user case .synthetics: self = .synthetics @@ -4158,7 +4686,7 @@ public enum DDRUMViewEventSessionSessionType: Int { } } - internal var toSwift: RUMViewEvent.Session.SessionType { + internal var toSwift: RUMSessionType { switch self { case .user: return .user case .synthetics: return .synthetics @@ -4207,7 +4735,7 @@ public enum DDRUMViewEventSource: Int { } @objc -public class DDRUMViewEventSynthetics: NSObject { +public class DDRUMViewEventRUMSyntheticsTest: NSObject { internal let root: DDRUMViewEvent internal init(root: DDRUMViewEvent) { @@ -5204,6 +5732,14 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject { root.swiftModel.telemetry.configuration.allowUntrustedEvents as NSNumber? } + @objc public var backgroundTasksEnabled: NSNumber? { + root.swiftModel.telemetry.configuration.backgroundTasksEnabled as NSNumber? + } + + @objc public var batchProcessingLevel: NSNumber? { + root.swiftModel.telemetry.configuration.batchProcessingLevel as NSNumber? + } + @objc public var batchSize: NSNumber? { root.swiftModel.telemetry.configuration.batchSize as NSNumber? } @@ -5544,4 +6080,4 @@ public class DDTelemetryConfigurationEventView: NSObject { // swiftlint:enable force_unwrapping -// Generated from https://github.com/DataDog/rum-events-format/tree/f69ca4664ed6e69c929855d02c4ce3d4b85d0bb4 +// Generated from https://github.com/DataDog/rum-events-format/tree/49a2345f61a948013208d66a0fa9bad15a8c8fab diff --git a/DatadogRUM.podspec b/DatadogRUM.podspec index 30189c186b..d9ebd4057c 100644 --- a/DatadogRUM.podspec +++ b/DatadogRUM.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogRUM" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog Real User Monitoring Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels.swift b/DatadogRUM/Sources/DataModels/RUMDataModels.swift index f31884fad2..cd790c156d 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels.swift @@ -21,6 +21,9 @@ public struct RUMActionEvent: RUMDataModel { /// Application properties public let application: Application + /// Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build. + public let buildId: String? + /// The build version for this application public let buildVersion: String? @@ -30,6 +33,9 @@ public struct RUMActionEvent: RUMDataModel { /// Device connectivity properties public let connectivity: RUMConnectivity? + /// View Container properties (view wrapping the current view) + public let container: Container? + /// User provided context public internal(set) var context: RUMEventAttributes? @@ -55,7 +61,7 @@ public struct RUMActionEvent: RUMDataModel { public let source: Source? /// Synthetics properties - public let synthetics: Synthetics? + public let synthetics: RUMSyntheticsTest? /// RUM event type public let type: String = "action" @@ -73,9 +79,11 @@ public struct RUMActionEvent: RUMDataModel { case dd = "_dd" case action = "action" case application = "application" + case buildId = "build_id" case buildVersion = "build_version" case ciTest = "ci_test" case connectivity = "connectivity" + case container = "container" case context = "context" case date = "date" case device = "device" @@ -181,8 +189,12 @@ public struct RUMActionEvent: RUMDataModel { /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) public let plan: Plan? + /// The precondition that led to the creation of the session + public let sessionPrecondition: RUMSessionPrecondition? + enum CodingKeys: String, CodingKey { case plan = "plan" + case sessionPrecondition = "session_precondition" } /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) @@ -324,6 +336,40 @@ public struct RUMActionEvent: RUMDataModel { } } + /// View Container properties (view wrapping the current view) + public struct Container: Codable { + /// Source of the parent view + public let source: Source + + /// Attributes of the view's container + public let view: View + + enum CodingKeys: String, CodingKey { + case source = "source" + case view = "view" + } + + /// Source of the parent view + public enum Source: String, Codable { + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case roku = "roku" + } + + /// Attributes of the view's container + public struct View: Codable { + /// ID of the parent view + public let id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + } + /// Display properties public struct Display: Codable { /// The viewport represents the rectangular area that is currently being viewed. Content outside the viewport is not visible onscreen until scrolled into view. @@ -357,20 +403,13 @@ public struct RUMActionEvent: RUMDataModel { public let id: String /// Type of the session - public let type: SessionType + public let type: RUMSessionType enum CodingKeys: String, CodingKey { case hasReplay = "has_replay" case id = "id" case type = "type" } - - /// Type of the session - public enum SessionType: String, Codable { - case user = "user" - case synthetics = "synthetics" - case ciTest = "ci_test" - } } /// The source of this event @@ -383,24 +422,6 @@ public struct RUMActionEvent: RUMDataModel { case roku = "roku" } - /// Synthetics properties - public struct Synthetics: Codable { - /// Whether the event comes from a SDK instance injected by Synthetics - public let injected: Bool? - - /// The identifier of the current Synthetics test results - public let resultId: String - - /// The identifier of the current Synthetics test - public let testId: String - - enum CodingKeys: String, CodingKey { - case injected = "injected" - case resultId = "result_id" - case testId = "test_id" - } - } - /// View properties public struct View: Codable { /// UUID of the view @@ -439,6 +460,9 @@ public struct RUMErrorEvent: RUMDataModel { /// Application properties public let application: Application + /// Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build. + public let buildId: String? + /// The build version for this application public let buildVersion: String? @@ -448,6 +472,9 @@ public struct RUMErrorEvent: RUMDataModel { /// Device connectivity properties public let connectivity: RUMConnectivity? + /// View Container properties (view wrapping the current view) + public let container: Container? + /// User provided context public internal(set) var context: RUMEventAttributes? @@ -479,7 +506,7 @@ public struct RUMErrorEvent: RUMDataModel { public let source: Source? /// Synthetics properties - public let synthetics: Synthetics? + public let synthetics: RUMSyntheticsTest? /// RUM event type public let type: String = "error" @@ -497,9 +524,11 @@ public struct RUMErrorEvent: RUMDataModel { case dd = "_dd" case action = "action" case application = "application" + case buildId = "build_id" case buildVersion = "build_version" case ciTest = "ci_test" case connectivity = "connectivity" + case container = "container" case context = "context" case date = "date" case device = "device" @@ -557,8 +586,12 @@ public struct RUMErrorEvent: RUMDataModel { /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) public let plan: Plan? + /// The precondition that led to the creation of the session + public let sessionPrecondition: RUMSessionPrecondition? + enum CodingKeys: String, CodingKey { case plan = "plan" + case sessionPrecondition = "session_precondition" } /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) @@ -589,6 +622,40 @@ public struct RUMErrorEvent: RUMDataModel { } } + /// View Container properties (view wrapping the current view) + public struct Container: Codable { + /// Source of the parent view + public let source: Source + + /// Attributes of the view's container + public let view: View + + enum CodingKeys: String, CodingKey { + case source = "source" + case view = "view" + } + + /// Source of the parent view + public enum Source: String, Codable { + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case roku = "roku" + } + + /// Attributes of the view's container + public struct View: Codable { + /// ID of the parent view + public let id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + } + /// Display properties public struct Display: Codable { /// The viewport represents the rectangular area that is currently being viewed. Content outside the viewport is not visible onscreen until scrolled into view. @@ -801,20 +868,13 @@ public struct RUMErrorEvent: RUMDataModel { public let id: String /// Type of the session - public let type: SessionType + public let type: RUMSessionType enum CodingKeys: String, CodingKey { case hasReplay = "has_replay" case id = "id" case type = "type" } - - /// Type of the session - public enum SessionType: String, Codable { - case user = "user" - case synthetics = "synthetics" - case ciTest = "ci_test" - } } /// The source of this event @@ -827,24 +887,6 @@ public struct RUMErrorEvent: RUMDataModel { case roku = "roku" } - /// Synthetics properties - public struct Synthetics: Codable { - /// Whether the event comes from a SDK instance injected by Synthetics - public let injected: Bool? - - /// The identifier of the current Synthetics test results - public let resultId: String - - /// The identifier of the current Synthetics test - public let testId: String - - enum CodingKeys: String, CodingKey { - case injected = "injected" - case resultId = "result_id" - case testId = "test_id" - } - } - /// View properties public struct View: Codable { /// UUID of the view @@ -907,6 +949,9 @@ public struct RUMLongTaskEvent: RUMDataModel { /// Application properties public let application: Application + /// Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build. + public let buildId: String? + /// The build version for this application public let buildVersion: String? @@ -916,6 +961,9 @@ public struct RUMLongTaskEvent: RUMDataModel { /// Device connectivity properties public let connectivity: RUMConnectivity? + /// View Container properties (view wrapping the current view) + public let container: Container? + /// User provided context public internal(set) var context: RUMEventAttributes? @@ -944,7 +992,7 @@ public struct RUMLongTaskEvent: RUMDataModel { public let source: Source? /// Synthetics properties - public let synthetics: Synthetics? + public let synthetics: RUMSyntheticsTest? /// RUM event type public let type: String = "long_task" @@ -962,9 +1010,11 @@ public struct RUMLongTaskEvent: RUMDataModel { case dd = "_dd" case action = "action" case application = "application" + case buildId = "build_id" case buildVersion = "build_version" case ciTest = "ci_test" case connectivity = "connectivity" + case container = "container" case context = "context" case date = "date" case device = "device" @@ -1025,8 +1075,12 @@ public struct RUMLongTaskEvent: RUMDataModel { /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) public let plan: Plan? + /// The precondition that led to the creation of the session + public let sessionPrecondition: RUMSessionPrecondition? + enum CodingKeys: String, CodingKey { case plan = "plan" + case sessionPrecondition = "session_precondition" } /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) @@ -1057,6 +1111,40 @@ public struct RUMLongTaskEvent: RUMDataModel { } } + /// View Container properties (view wrapping the current view) + public struct Container: Codable { + /// Source of the parent view + public let source: Source + + /// Attributes of the view's container + public let view: View + + enum CodingKeys: String, CodingKey { + case source = "source" + case view = "view" + } + + /// Source of the parent view + public enum Source: String, Codable { + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case roku = "roku" + } + + /// Attributes of the view's container + public struct View: Codable { + /// ID of the parent view + public let id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + } + /// Display properties public struct Display: Codable { /// The viewport represents the rectangular area that is currently being viewed. Content outside the viewport is not visible onscreen until scrolled into view. @@ -1108,20 +1196,13 @@ public struct RUMLongTaskEvent: RUMDataModel { public let id: String /// Type of the session - public let type: SessionType + public let type: RUMSessionType enum CodingKeys: String, CodingKey { case hasReplay = "has_replay" case id = "id" case type = "type" } - - /// Type of the session - public enum SessionType: String, Codable { - case user = "user" - case synthetics = "synthetics" - case ciTest = "ci_test" - } } /// The source of this event @@ -1134,24 +1215,6 @@ public struct RUMLongTaskEvent: RUMDataModel { case roku = "roku" } - /// Synthetics properties - public struct Synthetics: Codable { - /// Whether the event comes from a SDK instance injected by Synthetics - public let injected: Bool? - - /// The identifier of the current Synthetics test results - public let resultId: String - - /// The identifier of the current Synthetics test - public let testId: String - - enum CodingKeys: String, CodingKey { - case injected = "injected" - case resultId = "result_id" - case testId = "test_id" - } - } - /// View properties public struct View: Codable { /// UUID of the view @@ -1186,6 +1249,9 @@ public struct RUMResourceEvent: RUMDataModel { /// Application properties public let application: Application + /// Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build. + public let buildId: String? + /// The build version for this application public let buildVersion: String? @@ -1195,6 +1261,9 @@ public struct RUMResourceEvent: RUMDataModel { /// Device connectivity properties public let connectivity: RUMConnectivity? + /// View Container properties (view wrapping the current view) + public let container: Container? + /// User provided context public internal(set) var context: RUMEventAttributes? @@ -1223,7 +1292,7 @@ public struct RUMResourceEvent: RUMDataModel { public let source: Source? /// Synthetics properties - public let synthetics: Synthetics? + public let synthetics: RUMSyntheticsTest? /// RUM event type public let type: String = "resource" @@ -1241,9 +1310,11 @@ public struct RUMResourceEvent: RUMDataModel { case dd = "_dd" case action = "action" case application = "application" + case buildId = "build_id" case buildVersion = "build_version" case ciTest = "ci_test" case connectivity = "connectivity" + case container = "container" case context = "context" case date = "date" case device = "device" @@ -1316,8 +1387,12 @@ public struct RUMResourceEvent: RUMDataModel { /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) public let plan: Plan? + /// The precondition that led to the creation of the session + public let sessionPrecondition: RUMSessionPrecondition? + enum CodingKeys: String, CodingKey { case plan = "plan" + case sessionPrecondition = "session_precondition" } /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) @@ -1348,6 +1423,40 @@ public struct RUMResourceEvent: RUMDataModel { } } + /// View Container properties (view wrapping the current view) + public struct Container: Codable { + /// Source of the parent view + public let source: Source + + /// Attributes of the view's container + public let view: View + + enum CodingKeys: String, CodingKey { + case source = "source" + case view = "view" + } + + /// Source of the parent view + public enum Source: String, Codable { + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case roku = "roku" + } + + /// Attributes of the view's container + public struct View: Codable { + /// ID of the parent view + public let id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + } + /// Display properties public struct Display: Codable { /// The viewport represents the rectangular area that is currently being viewed. Content outside the viewport is not visible onscreen until scrolled into view. @@ -1611,20 +1720,13 @@ public struct RUMResourceEvent: RUMDataModel { public let id: String /// Type of the session - public let type: SessionType + public let type: RUMSessionType enum CodingKeys: String, CodingKey { case hasReplay = "has_replay" case id = "id" case type = "type" } - - /// Type of the session - public enum SessionType: String, Codable { - case user = "user" - case synthetics = "synthetics" - case ciTest = "ci_test" - } } /// The source of this event @@ -1637,24 +1739,6 @@ public struct RUMResourceEvent: RUMDataModel { case roku = "roku" } - /// Synthetics properties - public struct Synthetics: Codable { - /// Whether the event comes from a SDK instance injected by Synthetics - public let injected: Bool? - - /// The identifier of the current Synthetics test results - public let resultId: String - - /// The identifier of the current Synthetics test - public let testId: String - - enum CodingKeys: String, CodingKey { - case injected = "injected" - case resultId = "result_id" - case testId = "test_id" - } - } - /// View properties public struct View: Codable { /// UUID of the view @@ -1686,6 +1770,9 @@ public struct RUMViewEvent: RUMDataModel { /// Application properties public let application: Application + /// Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build. + public let buildId: String? + /// The build version for this application public let buildVersion: String? @@ -1695,6 +1782,9 @@ public struct RUMViewEvent: RUMDataModel { /// Device connectivity properties public let connectivity: RUMConnectivity? + /// View Container properties (view wrapping the current view) + public let container: Container? + /// User provided context public internal(set) var context: RUMEventAttributes? @@ -1726,7 +1816,7 @@ public struct RUMViewEvent: RUMDataModel { public let source: Source? /// Synthetics properties - public let synthetics: Synthetics? + public let synthetics: RUMSyntheticsTest? /// RUM event type public let type: String = "view" @@ -1743,9 +1833,11 @@ public struct RUMViewEvent: RUMDataModel { enum CodingKeys: String, CodingKey { case dd = "_dd" case application = "application" + case buildId = "build_id" case buildVersion = "build_version" case ciTest = "ci_test" case connectivity = "connectivity" + case container = "container" case context = "context" case date = "date" case device = "device" @@ -1804,9 +1896,13 @@ public struct RUMViewEvent: RUMDataModel { /// The percentage of sessions tracked public let sessionSampleRate: Double + /// Whether session replay recording configured to start manually + public let startSessionReplayRecordingManually: Bool? + enum CodingKeys: String, CodingKey { case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" + case startSessionReplayRecordingManually = "start_session_replay_recording_manually" } } @@ -1856,8 +1952,12 @@ public struct RUMViewEvent: RUMDataModel { /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) public let plan: Plan? + /// The precondition that led to the creation of the session + public let sessionPrecondition: RUMSessionPrecondition? + enum CodingKeys: String, CodingKey { case plan = "plan" + case sessionPrecondition = "session_precondition" } /// Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated) @@ -1878,6 +1978,40 @@ public struct RUMViewEvent: RUMDataModel { } } + /// View Container properties (view wrapping the current view) + public struct Container: Codable { + /// Source of the parent view + public let source: Source + + /// Attributes of the view's container + public let view: View + + enum CodingKeys: String, CodingKey { + case source = "source" + case view = "view" + } + + /// Source of the parent view + public enum Source: String, Codable { + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case roku = "roku" + } + + /// Attributes of the view's container + public struct View: Codable { + /// ID of the parent view + public let id: String + + enum CodingKeys: String, CodingKey { + case id = "id" + } + } + } + /// Display properties public struct Display: Codable { /// Scroll properties @@ -1964,36 +2098,16 @@ public struct RUMViewEvent: RUMDataModel { /// Whether this session has been sampled for replay public let sampledForReplay: Bool? - /// The precondition that led to the creation of the session - public let startPrecondition: StartPrecondition? - /// Type of the session - public let type: SessionType + public let type: RUMSessionType enum CodingKeys: String, CodingKey { case hasReplay = "has_replay" case id = "id" case isActive = "is_active" case sampledForReplay = "sampled_for_replay" - case startPrecondition = "start_precondition" case type = "type" } - - /// The precondition that led to the creation of the session - public enum StartPrecondition: String, Codable { - case appLaunch = "app_launch" - case inactivityTimeout = "inactivity_timeout" - case maxDuration = "max_duration" - case explicitStop = "explicit_stop" - case backgroundEvent = "background_event" - } - - /// Type of the session - public enum SessionType: String, Codable { - case user = "user" - case synthetics = "synthetics" - case ciTest = "ci_test" - } } /// The source of this event @@ -2006,24 +2120,6 @@ public struct RUMViewEvent: RUMDataModel { case roku = "roku" } - /// Synthetics properties - public struct Synthetics: Codable { - /// Whether the event comes from a SDK instance injected by Synthetics - public let injected: Bool? - - /// The identifier of the current Synthetics test results - public let resultId: String - - /// The identifier of the current Synthetics test - public let testId: String - - enum CodingKeys: String, CodingKey { - case injected = "injected" - case resultId = "result_id" - case testId = "test_id" - } - } - /// View properties public struct View: Codable { /// Properties of the actions of the view @@ -2826,6 +2922,12 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// Whether untrusted events are allowed public let allowUntrustedEvents: Bool? + /// Whether UIApplication background tasks are enabled + public let backgroundTasksEnabled: Bool? + + /// Maximum number of batches processed sequencially without a delay + public let batchProcessingLevel: Int64? + /// The window duration for batches sent by the SDK (in milliseconds) public let batchSize: Int64? @@ -2977,6 +3079,8 @@ public struct TelemetryConfigurationEvent: RUMDataModel { case actionNameAttribute = "action_name_attribute" case allowFallbackToLocalStorage = "allow_fallback_to_local_storage" case allowUntrustedEvents = "allow_untrusted_events" + case backgroundTasksEnabled = "background_tasks_enabled" + case batchProcessingLevel = "batch_processing_level" case batchSize = "batch_size" case batchUploadFrequency = "batch_upload_frequency" case dartVersion = "dart_version" @@ -3140,6 +3244,17 @@ public struct TelemetryConfigurationEvent: RUMDataModel { } } +/// The precondition that led to the creation of the session +public enum RUMSessionPrecondition: String, Codable { + case userAppLaunch = "user_app_launch" + case inactivityTimeout = "inactivity_timeout" + case maxDuration = "max_duration" + case backgroundLaunch = "background_launch" + case prewarm = "prewarm" + case fromNonInteractiveSession = "from_non_interactive_session" + case explicitStop = "explicit_stop" +} + /// CI Visibility properties public struct RUMCITest: Codable { /// The identifier of the current CI Visibility test execution @@ -3289,6 +3404,31 @@ public struct RUMOperatingSystem: Codable { } } +/// Type of the session +public enum RUMSessionType: String, Codable { + case user = "user" + case synthetics = "synthetics" + case ciTest = "ci_test" +} + +/// Synthetics properties +public struct RUMSyntheticsTest: Codable { + /// Whether the event comes from a SDK instance injected by Synthetics + public let injected: Bool? + + /// The identifier of the current Synthetics test results + public let resultId: String + + /// The identifier of the current Synthetics test + public let testId: String + + enum CodingKeys: String, CodingKey { + case injected = "injected" + case resultId = "result_id" + case testId = "test_id" + } +} + /// User properties public struct RUMUser: Codable { /// Email of the user @@ -3398,4 +3538,4 @@ public enum RUMMethod: String, Codable { case patch = "PATCH" } -// Generated from https://github.com/DataDog/rum-events-format/tree/f69ca4664ed6e69c929855d02c4ce3d4b85d0bb4 +// Generated from https://github.com/DataDog/rum-events-format/tree/49a2345f61a948013208d66a0fa9bad15a8c8fab diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 86c1358c03..d88d1e290e 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -18,10 +18,14 @@ internal final class RUMFeature: DatadogRemoteFeature { let instrumentation: RUMInstrumentation + let configuration: RUM.Configuration + init( in core: DatadogCoreProtocol, configuration: RUM.Configuration ) throws { + self.configuration = configuration + let dependencies = RUMScopeDependencies( core: core, rumApplicationID: configuration.applicationID, @@ -50,6 +54,13 @@ internal final class RUMFeature: DatadogRemoteFeature { ), rumUUIDGenerator: configuration.uuidGenerator, ciTest: configuration.ciTestExecutionID.map { RUMCITest(testExecutionId: $0) }, + syntheticsTest: { + if let testId = configuration.syntheticsTestId, let resultId = configuration.syntheticsResultId { + return RUMSyntheticsTest(injected: nil, resultId: resultId, testId: testId) + } else { + return nil + } + }(), vitalsReaders: configuration.vitalsUpdateFrequency.map { VitalsReaders( frequency: $0.timeInterval, @@ -95,6 +106,13 @@ internal final class RUMFeature: DatadogRemoteFeature { trackBackgroundEvents: configuration.trackBackgroundEvents, uuidGenerator: configuration.uuidGenerator, ciTest: configuration.ciTestExecutionID.map { RUMCITest(testExecutionId: $0) }, + syntheticsTest: { + if let testId = configuration.syntheticsTestId, let resultId = configuration.syntheticsResultId { + return RUMSyntheticsTest(injected: nil, resultId: resultId, testId: testId) + } else { + return nil + } + }(), telemetry: core.telemetry ) ) diff --git a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift index 535fc71c32..acf02b7a3e 100644 --- a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift +++ b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift @@ -30,11 +30,11 @@ internal class UIApplicationSwizzler { @convention(block) (UIApplication, UIEvent) -> Bool > { private static let selector = #selector(UIApplication.sendEvent(_:)) - private let method: FoundMethod + private let method: Method private let handler: UIEventHandler init(handler: UIEventHandler) throws { - self.method = try Self.findMethod(with: Self.selector, in: UIApplication.self) + self.method = try dd_class_getInstanceMethod(UIApplication.self, Self.selector) self.handler = handler } diff --git a/DatadogRUM/Sources/Instrumentation/Resources/URLSessionRUMResourcesHandler.swift b/DatadogRUM/Sources/Instrumentation/Resources/URLSessionRUMResourcesHandler.swift index 6d3baf5edb..211317bdb2 100644 --- a/DatadogRUM/Sources/Instrumentation/Resources/URLSessionRUMResourcesHandler.swift +++ b/DatadogRUM/Sources/Instrumentation/Resources/URLSessionRUMResourcesHandler.swift @@ -87,7 +87,7 @@ internal final class URLSessionRUMResourcesHandler: DatadogURLSessionHandler, RU guard let subscriber = subscriber else { return DD.logger.warn( """ - RUM Resource was completed, but no `RUMMonitor` is initiaized in the core. RUM auto instrumentation will not work. + RUM Resource was completed, but no `RUMMonitor` is initialized in the core. RUM auto instrumentation will not work. Make sure `RUMMonitor.initialize()` is called before any network request is send. """ ) diff --git a/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift b/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift index 9dc5b80807..5d805a4319 100644 --- a/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift +++ b/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift @@ -12,13 +12,13 @@ internal final class RUMViewsHandler { /// RUM representation of a View. private struct View { /// The RUM View identity. - let identity: RUMViewIdentity + let identity: ViewIdentifier /// View name used for RUM Explorer. let name: String /// View path used for RUM Explorer. - let path: String? + let path: String /// Whether the view is modal, but untracked (should not send start / stop commands) let isUntrackedModal: Bool @@ -101,7 +101,7 @@ internal final class RUMViewsHandler { private func add(view: View) { // Ignore the view if it's already visible - if view.identity.equals(stack.last?.identity) { + if view.identity == stack.last?.identity { return } @@ -116,15 +116,15 @@ internal final class RUMViewsHandler { } // Add/Move the appearing view to the top - stack.removeAll(where: { $0.identity.equals(view.identity) }) + stack.removeAll(where: { $0.identity == view.identity }) stack.append(view) } - private func remove(identity: RUMViewIdentity) { - guard identity.equals(stack.last?.identity) else { + private func remove(identity: ViewIdentifier) { + guard identity == stack.last?.identity else { // Remove any disappearing view from the stack if // it's not visible. - return stack.removeAll(where: { $0.identity.equals(identity) }) + return stack.removeAll(where: { $0.identity == identity }) } // Stop and remove the visible view from the stack @@ -151,10 +151,6 @@ internal final class RUMViewsHandler { return } - guard view.identity.exists else { - return - } - subscriber.process( command: RUMStartViewCommand( time: dateProvider.now, @@ -167,10 +163,6 @@ internal final class RUMViewsHandler { } private func stop(view: View) { - guard view.identity.exists else { - return - } - guard !view.isUntrackedModal else { return } @@ -201,8 +193,8 @@ internal final class RUMViewsHandler { extension RUMViewsHandler: UIViewControllerHandler { func notify_viewDidAppear(viewController: UIViewController, animated: Bool) { - let identity = viewController.asRUMViewIdentity() - if let view = stack.first(where: { $0.identity.equals(identity) }) { + let identity = ViewIdentifier(viewController) + if let view = stack.first(where: { $0.identity == identity }) { // If the stack already contains the view controller, just restarts the view. // This prevents from calling the predicate when unnecessary. add(view: view) @@ -211,7 +203,7 @@ extension RUMViewsHandler: UIViewControllerHandler { view: .init( identity: identity, name: rumView.name, - path: rumView.path, + path: rumView.path ?? viewController.canonicalClassName, isUntrackedModal: rumView.isUntrackedModal, attributes: rumView.attributes ) @@ -221,7 +213,7 @@ extension RUMViewsHandler: UIViewControllerHandler { view: .init( identity: identity, name: "RUMUntrackedModal", - path: nil, + path: viewController.canonicalClassName, isUntrackedModal: true, attributes: [:] ) @@ -230,7 +222,7 @@ extension RUMViewsHandler: UIViewControllerHandler { } func notify_viewDidDisappear(viewController: UIViewController, animated: Bool) { - remove(identity: viewController.asRUMViewIdentity()) + remove(identity: ViewIdentifier(viewController)) } } @@ -244,7 +236,7 @@ extension RUMViewsHandler: SwiftUIViewHandler { func notify_onAppear(identity: String, name: String, path: String, attributes: [AttributeKey: AttributeValue]) { add( view: .init( - identity: identity.asRUMViewIdentity(), + identity: ViewIdentifier(identity), name: name, path: path, isUntrackedModal: false, @@ -257,6 +249,6 @@ extension RUMViewsHandler: SwiftUIViewHandler { /// /// - Parameter key: The disappearing `SwiftUI.View` key. func notify_onDisappear(identity: String) { - remove(identity: identity.asRUMViewIdentity()) + remove(identity: ViewIdentifier(identity)) } } diff --git a/DatadogRUM/Sources/Instrumentation/Views/UIKit/UIViewControllerSwizzler.swift b/DatadogRUM/Sources/Instrumentation/Views/UIKit/UIViewControllerSwizzler.swift index 75c731ab7b..396b4d477d 100644 --- a/DatadogRUM/Sources/Instrumentation/Views/UIKit/UIViewControllerSwizzler.swift +++ b/DatadogRUM/Sources/Instrumentation/Views/UIKit/UIViewControllerSwizzler.swift @@ -34,11 +34,11 @@ internal class UIViewControllerSwizzler { @convention(block) (UIViewController, Bool) -> Void > { private static let selector = #selector(UIViewController.viewDidAppear(_:)) - private let method: FoundMethod + private let method: Method private let handler: UIViewControllerHandler init(handler: UIViewControllerHandler) throws { - self.method = try Self.findMethod(with: Self.selector, in: UIViewController.self) + self.method = try dd_class_getInstanceMethod(UIViewController.self, Self.selector) self.handler = handler } @@ -59,11 +59,11 @@ internal class UIViewControllerSwizzler { @convention(block) (UIViewController, Bool) -> Void > { private static let selector = #selector(UIViewController.viewDidDisappear(_:)) - private let method: FoundMethod + private let method: Method private let handler: UIViewControllerHandler init(handler: UIViewControllerHandler) throws { - self.method = try Self.findMethod(with: Self.selector, in: UIViewController.self) + self.method = try dd_class_getInstanceMethod(UIViewController.self, Self.selector) self.handler = handler } diff --git a/DatadogRUM/Sources/Integrations/CrashReportReceiver.swift b/DatadogRUM/Sources/Integrations/CrashReportReceiver.swift index 4d77bc77c3..ac88d96f75 100644 --- a/DatadogRUM/Sources/Integrations/CrashReportReceiver.swift +++ b/DatadogRUM/Sources/Integrations/CrashReportReceiver.swift @@ -63,6 +63,8 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { let device: DeviceInfo /// The version of the application that data is generated from. let version: String + /// The build Id of the applicaiton that data is generated from + let buildId: String? /// The build number of the application that data is generated from. let buildNumber: String /// Denotes the mobile application's platform, such as `"ios"` or `"flutter"` that data is generated from. @@ -105,6 +107,8 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { let uuidGenerator: RUMUUIDGenerator /// Integration with CIApp tests. It contains the CIApp test context when active. let ciTest: RUMCITest? + /// Integration with Synthetics tests. It contains the Synthetics test context when active. + let syntheticsTest: RUMSyntheticsTest? /// Telemetry interface. let telemetry: Telemetry @@ -117,6 +121,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { trackBackgroundEvents: Bool, uuidGenerator: RUMUUIDGenerator, ciTest: RUMCITest?, + syntheticsTest: RUMSyntheticsTest?, telemetry: Telemetry ) { self.applicationID = applicationID @@ -125,6 +130,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { self.trackBackgroundEvents = trackBackgroundEvents self.uuidGenerator = uuidGenerator self.ciTest = ciTest + self.syntheticsTest = syntheticsTest self.telemetry = telemetry } @@ -176,7 +182,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { return true } - /// If the crash occured in an existing RUM session and we know its `lastRUMViewEvent` we send the error using that session UUID and link + /// If the crash occurred in an existing RUM session and we know its `lastRUMViewEvent` we send the error using that session UUID and link /// the crash to that view. The error event can be preceded with a view update based on `Constants.viewEventAvailabilityThreshold` condition. private func sendCrashReportLinkedToLastViewInPreviousSession( _ crashReport: CrashReport, @@ -197,7 +203,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { } } - /// If the crash occured in an existing RUM session and we know its `lastRUMSessionState` but there was no `lastRUMViewEvent` we can + /// If the crash occurred in an existing RUM session and we know its `lastRUMSessionState` but there was no `lastRUMViewEvent` we can /// still send the error using that session UUID. Lack of `lastRUMViewEvent` means that there was no **active** view, but the presence of /// `lastRUMSessionState` indicates that some views were tracked before. private func sendCrashReportToPreviousSession( @@ -229,7 +235,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { hasReplay: lastRUMSessionState.didStartWithReplay ) case .handleInBackgroundView: - // It means that the crash occured as the very first event after sending app to background in previous session. + // It means that the crash occurred as the very first event after sending app to background in previous session. // This is why we don't have the `lastRUMViewEvent` (no view was active), but we know the `lastRUMSessionState`. newRUMView = createNewRUMViewEvent( named: RUMOffViewEventsHandlingRule.Constants.backgroundViewName, @@ -276,7 +282,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { startDate: crashTimings.realCrashDate, sessionUUID: uuidGenerator.generateUnique(), // create new RUM session context: crashContext, - // As the crash occured after initializing SDK but before starting the first view, + // As the crash occurred after initializing SDK but before starting the first view, // we can't know if Session Replay was configured. However, lack of view implies // that there must be no replay collected: hasReplay: false @@ -288,7 +294,7 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { startDate: crashTimings.realCrashDate, sessionUUID: uuidGenerator.generateUnique(), // create new RUM session context: crashContext, - // As the crash occured after initializing SDK but before starting the first view, + // As the crash occurred after initializing SDK but before starting the first view, // we can't know if Session Replay was configured. However, lack of view implies // that there must be no replay collected: hasReplay: false @@ -335,13 +341,18 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { dd: .init( browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.sessionSampler.samplingRate)), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: lastRUMView.dd.session?.sessionPrecondition + ) ), action: nil, application: .init(id: lastRUMView.application.id), + buildId: lastRUMView.buildId, buildVersion: lastRUMView.buildVersion, ciTest: lastRUMView.ciTest, connectivity: lastRUMView.connectivity, + container: nil, context: lastRUMView.context, date: crashDate.timeIntervalSince1970.toInt64Milliseconds, device: lastRUMView.device, @@ -363,10 +374,10 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { session: .init( hasReplay: lastRUMView.session.hasReplay, id: lastRUMView.session.id, - type: lastRUMView.ciTest != nil ? .ciTest : .user + type: lastRUMView.session.type ), source: lastRUMView.source?.toErrorEventSource ?? .ios, - synthetics: nil, + synthetics: lastRUMView.synthetics, usr: lastRUMView.usr, version: lastRUMView.version, view: .init( @@ -389,16 +400,25 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { return RUMViewEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.sessionSampler.samplingRate)), + configuration: .init( + sessionReplaySampleRate: nil, + sessionSampleRate: Double(self.sessionSampler.samplingRate), + startSessionReplayRecordingManually: nil + ), documentVersion: original.dd.documentVersion + 1, pageStates: nil, replayStats: nil, - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: original.dd.session?.sessionPrecondition + ) ), application: original.application, + buildId: original.buildId, buildVersion: original.buildVersion, ciTest: original.ciTest, connectivity: original.connectivity, + container: nil, context: original.context, date: crashDate.timeIntervalSince1970.toInt64Milliseconds - 1, // -1ms to put the crash after view in RUM session device: original.device, @@ -472,21 +492,30 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { return RUMViewEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(self.sessionSampler.samplingRate)), + configuration: .init( + sessionReplaySampleRate: nil, + sessionSampleRate: Double(self.sessionSampler.samplingRate), + startSessionReplayRecordingManually: nil + ), documentVersion: 1, pageStates: nil, replayStats: nil, - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: nil + ) ), application: .init( id: applicationID ), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: ciTest, connectivity: RUMConnectivity( networkInfo: context.networkConnectionInfo, carrierInfo: context.carrierInfo ), + container: nil, context: nil, date: startDate.timeIntervalSince1970.toInt64Milliseconds, device: .init(device: context.device, telemetry: telemetry), @@ -503,11 +532,10 @@ internal struct CrashReportReceiver: FeatureMessageReceiver { id: sessionUUID.toRUMDataFormat, isActive: true, sampledForReplay: nil, - startPrecondition: nil, - type: ciTest != nil ? .ciTest : .user + type: ciTest != nil ? .ciTest : (syntheticsTest != nil ? .synthetics : .user) ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: syntheticsTest, usr: context.userInfo.map { RUMUser(userInfo: $0) }, version: context.version, view: .init( diff --git a/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift b/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift index f8447f495c..4ade30655f 100644 --- a/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift +++ b/DatadogRUM/Sources/Integrations/TelemetryReceiver.swift @@ -260,6 +260,8 @@ private extension TelemetryConfigurationEvent.Telemetry.Configuration { actionNameAttribute: nil, allowFallbackToLocalStorage: nil, allowUntrustedEvents: nil, + backgroundTasksEnabled: configuration.backgroundTasksEnabled, + batchProcessingLevel: configuration.batchProcessingLevel, batchSize: configuration.batchSize, batchUploadFrequency: configuration.batchUploadFrequency, dartVersion: configuration.dartVersion, diff --git a/DatadogRUM/Sources/RUM+Internal.swift b/DatadogRUM/Sources/RUM+Internal.swift index d99d2df12b..a06e60bc98 100644 --- a/DatadogRUM/Sources/RUM+Internal.swift +++ b/DatadogRUM/Sources/RUM+Internal.swift @@ -9,6 +9,8 @@ import DatadogInternal extension RUM: InternalExtended {} +/// NOTE: Methods in this extension are NOT considered part of the public of the Datadog SDK, and +/// may change or be removed in minor updates of the Datadog SDK. extension InternalExtension where ExtendedType == RUM { /// Check whether `RUM` has been enabled for a specific SDK instance. /// @@ -19,4 +21,58 @@ extension InternalExtension where ExtendedType == RUM { public static func isEnabled(in core: DatadogCoreProtocol = CoreRegistry.default) -> Bool { return core.get(feature: RUMFeature.self) != nil } + + /// Enable URL session tracking after RUM has already been enabled. This method + /// is only needed if the configuration of URL session tracking is not known at initialization time, + /// or in the case of cross platform frameworks that do not initalize native URL session tracking. + /// + /// - Parameters: + /// - configuration: the configuration for URL session tracking + /// - in: the core to enable URL session in + public static func enableURLSessionTracking( + with configuration: RUM.Configuration.URLSessionTracking, + in core: DatadogCoreProtocol = CoreRegistry.default) throws { + guard !(core is NOPDatadogCore) else { + throw ProgrammerError( + description: "Datadog SDK and RUM must be initialized before calling `RUM.enableUrlSessionTracking`." + ) + } + + guard let rum = core.get(feature: RUMFeature.self) else { + throw ProgrammerError( + description: "RUM must be initialized before calling `RUM.enableUrlSessionTracking`." + ) + } + + let distributedTracing: DistributedTracing? + let rumConfiguration = rum.configuration + + // If first party hosts are configured, enable distributed tracing: + switch configuration.firstPartyHostsTracing { + case let .trace(hosts, sampleRate): + distributedTracing = DistributedTracing( + sampler: Sampler(samplingRate: rumConfiguration.debugSDK ? 100 : sampleRate), + firstPartyHosts: FirstPartyHosts(hosts), + traceIDGenerator: rumConfiguration.traceIDGenerator + ) + case let .traceWithHeaders(hostsWithHeaders, sampleRate): + distributedTracing = DistributedTracing( + sampler: Sampler(samplingRate: rumConfiguration.debugSDK ? 100 : sampleRate), + firstPartyHosts: FirstPartyHosts(hostsWithHeaders), + traceIDGenerator: rumConfiguration.traceIDGenerator + ) + case .none: + distributedTracing = nil + } + + let urlSessionHandler = URLSessionRUMResourcesHandler( + dateProvider: rumConfiguration.dateProvider, + rumAttributesProvider: configuration.resourceAttributesProvider, + distributedTracing: distributedTracing + ) + + // Connect URLSession instrumentation to RUM monitor: + urlSessionHandler.publish(to: rum.monitor) + try core.register(urlSessionHandler: urlSessionHandler) + } } diff --git a/DatadogRUM/Sources/RUM.swift b/DatadogRUM/Sources/RUM.swift index edceaf8cb5..106d2cfce3 100644 --- a/DatadogRUM/Sources/RUM.swift +++ b/DatadogRUM/Sources/RUM.swift @@ -5,9 +5,10 @@ */ import DatadogInternal +import Foundation /// An entry point to Datadog RUM feature. -public struct RUM { +public enum RUM { /// Enables Datadog RUM feature. /// /// After RUM is enabled, use `RUMMonitor.shared(in:)` to collect RUM events. @@ -40,35 +41,7 @@ public struct RUM { // If resource tracking is configured, register URLSessionHandler to enable network instrumentation: if let urlSessionConfig = configuration.urlSessionTracking { - let distributedTracing: DistributedTracing? - - // If first party hosts are configured, enable distributed tracing: - switch urlSessionConfig.firstPartyHostsTracing { - case let .trace(hosts, sampleRate): - distributedTracing = DistributedTracing( - sampler: Sampler(samplingRate: configuration.debugSDK ? 100 : sampleRate), - firstPartyHosts: FirstPartyHosts(hosts), - traceIDGenerator: configuration.traceIDGenerator - ) - case let .traceWithHeaders(hostsWithHeaders, sampleRate): - distributedTracing = DistributedTracing( - sampler: Sampler(samplingRate: configuration.debugSDK ? 100 : sampleRate), - firstPartyHosts: FirstPartyHosts(hostsWithHeaders), - traceIDGenerator: configuration.traceIDGenerator - ) - case .none: - distributedTracing = nil - } - - let urlSessionHandler = URLSessionRUMResourcesHandler( - dateProvider: configuration.dateProvider, - rumAttributesProvider: urlSessionConfig.resourceAttributesProvider, - distributedTracing: distributedTracing - ) - - // Connect URLSession instrumentation to RUM monitor: - urlSessionHandler.publish(to: rum.monitor) - try core.register(urlSessionHandler: urlSessionHandler) + try RUM._internal.enableURLSessionTracking(with: urlSessionConfig, in: core) } if configuration.debugViews { diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index f1485bdf80..e1c4824ca3 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -260,6 +260,8 @@ extension RUM { internal var debugSDK: Bool = ProcessInfo.processInfo.arguments.contains(LaunchArguments.Debug) internal var debugViews: Bool = ProcessInfo.processInfo.arguments.contains("DD_DEBUG_RUM") internal var ciTestExecutionID: String? = ProcessInfo.processInfo.environment["CI_VISIBILITY_TEST_EXECUTION_ID"] + internal var syntheticsTestId: String? = ProcessInfo.processInfo.environment["_dd.synthetics.test_id"] + internal var syntheticsResultId: String? = ProcessInfo.processInfo.environment["_dd.synthetics.result_id"] } } diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index c813b28553..29545ecfdd 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -13,6 +13,8 @@ internal struct RUMContext { var sessionID: RUMUUID /// Whether the session for this context is currently active var isSessionActive: Bool + /// The precondition that led to the creation of current session. + var sessionPrecondition: RUMSessionPrecondition? /// The ID of currently displayed view. var activeViewID: RUMUUID? diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 07730ee41a..7ba3cdd1ab 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -211,6 +211,27 @@ extension Monitor: RUMMonitorProtocol { // MARK: - session + func currentSessionID(completion: @escaping (String?) -> Void) { + // Even though we're not writing anything, need to get the write context + // to make sure we're returning the correct sessionId after all other + // events have processed. + core?.scope(for: RUMFeature.name)?.eventWriteContext { _, _ in + self.queue.sync { + guard let sessionId = self.scopes.activeSession?.sessionUUID else { + completion(nil) + return + } + + var sessionIdValue: String? = nil + if sessionId != RUMUUID.nullUUID { + sessionIdValue = sessionId.rawValue.uuidString + } + + completion(sessionIdValue) + } + } + } + func stopSession() { process(command: RUMStopSessionCommand(time: dateProvider.now)) } @@ -221,9 +242,9 @@ extension Monitor: RUMMonitorProtocol { process( command: RUMStartViewCommand( time: dateProvider.now, - identity: viewController.asRUMViewIdentity(), - name: name, - path: nil, + identity: ViewIdentifier(viewController), + name: name ?? viewController.canonicalClassName, + path: viewController.canonicalClassName, attributes: attributes ) ) @@ -234,7 +255,7 @@ extension Monitor: RUMMonitorProtocol { command: RUMStopViewCommand( time: dateProvider.now, attributes: attributes, - identity: viewController.asRUMViewIdentity() + identity: ViewIdentifier(viewController) ) ) } @@ -243,7 +264,7 @@ extension Monitor: RUMMonitorProtocol { process( command: RUMStartViewCommand( time: dateProvider.now, - identity: key.asRUMViewIdentity(), + identity: ViewIdentifier(key), name: name ?? key, path: key, attributes: attributes @@ -256,7 +277,7 @@ extension Monitor: RUMMonitorProtocol { command: RUMStopViewCommand( time: dateProvider.now, attributes: attributes, - identity: key.asRUMViewIdentity() + identity: ViewIdentifier(key) ) ) } diff --git a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift index c0b25c1365..c40648f56c 100644 --- a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift +++ b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift @@ -54,7 +54,7 @@ internal struct RUMStartViewCommand: RUMCommand, RUMViewScopePropagatableAttribu let isUserInteraction = true // a new View means there was a navigation, it's considered a User interaction /// The value holding stable identity of the RUM View. - let identity: RUMViewIdentity + let identity: ViewIdentifier /// The name of this View, rendered in RUM Explorer as `VIEW NAME`. let name: String @@ -64,16 +64,16 @@ internal struct RUMStartViewCommand: RUMCommand, RUMViewScopePropagatableAttribu init( time: Date, - identity: RUMViewIdentity, - name: String?, - path: String?, + identity: ViewIdentifier, + name: String, + path: String, attributes: [AttributeKey: AttributeValue] ) { self.time = time self.attributes = attributes self.identity = identity - self.name = name ?? identity.defaultViewPath - self.path = path ?? identity.defaultViewPath + self.name = name + self.path = path } } @@ -84,7 +84,7 @@ internal struct RUMStopViewCommand: RUMCommand, RUMViewScopePropagatableAttribut let isUserInteraction = false // a view can be stopped and in most cases should not be considered an interaction (if it's stopped because the user navigate inside the same app, the startView will happen shortly after this) /// The value holding stable identity of the RUM View. - let identity: RUMViewIdentity + let identity: ViewIdentifier } internal struct RUMAddCurrentViewErrorCommand: RUMCommand { diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift index 70156fea56..5279ad4711 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift @@ -18,9 +18,12 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { /// Might be re-created later according to session duration constraints. private(set) var sessionScopes: [RUMSessionScope] = [] - /// Last active view from the last active session. Used to restart the active view on a user action. + /// Last active view from the last active session. Used to restart the active view on a user action. private var lastActiveView: RUMViewScope? + /// The end reason from the last active session. Used as "start reason" for the new session. + private var lastSessionEndReason: RUMSessionScope.EndReason? + var activeSession: RUMSessionScope? { get { return sessionScopes.first(where: { $0.isActive }) } } @@ -50,20 +53,31 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { func process(command: RUMCommand, context: DatadogContext, writer: Writer) -> Bool { // `RUMSDKInitCommand` forces the creation of the initial session + // Added in https://github.com/DataDog/dd-sdk-ios/pull/1278 to ensure that logs and traces + // can be correlated with valid RUM session id (even if occurring before any user interaction). if command is RUMSDKInitCommand { - createInitialSession(on: command, context: context, writer: writer) - return true + createInitialSession(with: context, on: command) + + // If the app was started by a user (foreground & not prewarmed): + if context.applicationStateHistory.currentSnapshot.state == .active && context.launchTime?.isActivePrewarm == false { + // Start "ApplicationLaunch" view immediatelly: + startApplicationLaunchView(on: command, context: context, writer: writer) + } + return true // always keep application scope } - // If the application has not been yet activated and no sessions exist - // -> create the initial session + // If the application has not been yet activated and no sessions exist -> create the initial session + // Added in https://github.com/DataDog/dd-sdk-ios/pull/1219 to start new session automatically when + // a user action is sent (startView or addUserAction). if sessionScopes.isEmpty && !applicationActive { - createInitialSession(on: command, context: context, writer: writer) + // This flow is likely stale code as`RUMSDKInitCommand` should already start the session before reaching this point + dependencies.telemetry.debug("Starting initial session from lazy flow") + createInitialSession(with: context, on: command) } // Create the application launch view on any command if !applicationActive { - applicationStart(on: command, context: context, writer: writer) + startApplicationLaunchView(on: command, context: context, writer: writer) } if activeSession == nil && command.isUserInteraction { @@ -85,75 +99,123 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { return scope } - // proccss(command:context:writer) returned false, but if the scope is still active - // it means we timed out or expired and we need to refresh the session - if scope.isActive { - return refresh(expiredSession: scope, on: command, context: context, writer: writer) + // proccss(command:context:writer) returned false, but if the scope is still active + // it means the session reached one of the end reasons + guard let endReason = scope.endReason else { + // Sanity telemetry, we don't expect reaching this flow + dependencies.telemetry.error("A session has ended with no 'end reason'") + return nil } - // Else, an inactive scope is done processing events and can be removed - return nil + // Store "end reason" so it will be used as "start reason" for next session + lastSessionEndReason = endReason + + switch endReason { + case .timeOut, .maxDuration: + // Replace this session scope with the scope for refreshed session: + return refresh(expiredSession: scope, on: command, context: context, writer: writer) + case .stopAPI: + // Remove this session scope (a new on will be started upon receiving user interaction): + return nil + } }) // Sanity telemety, only end up with one active session - if sessionScopes.filter({ $0.isActive }).count > 1 { - dependencies.telemetry.error("An application has multiple active sessions!") + let activeSessions = sessionScopes.filter { $0.isActive } + if activeSessions.count > 1 { + dependencies.telemetry.error("An application has \(activeSessions.count) active sessions") } - return activeSession != nil + return true // always keep application scope } // MARK: - Private - private func refresh(expiredSession: RUMSessionScope, on command: RUMCommand, context: DatadogContext, writer: Writer) -> RUMSessionScope { - let refreshedSession = RUMSessionScope(from: expiredSession, startTime: command.time, context: context) - sessionScopeDidUpdate(refreshedSession) - _ = refreshedSession.process(command: command, context: context, writer: writer) - return refreshedSession - } + /// Sanity count to make sure initial session is created only once. + private var didCreateInitialSessionCount = 0 + + /// Starts initial RUM Session. + private func createInitialSession(with context: DatadogContext, on command: RUMCommand) { + if didCreateInitialSessionCount > 0 { // Sanity check + dependencies.telemetry.error("Creating initial session \(didCreateInitialSessionCount) extra time(s) due to \(type(of: command)) (previous end reason: \(lastSessionEndReason?.rawValue ?? "unknown"))") + } + didCreateInitialSessionCount += 1 + + var startPrecondition: RUMSessionPrecondition? = nil + + if context.launchTime?.isActivePrewarm == true { + startPrecondition = .prewarm + } else if context.applicationStateHistory.currentSnapshot.state == .background { + startPrecondition = .backgroundLaunch + } else { + startPrecondition = .userAppLaunch + } - private func createInitialSession(on command: RUMCommand, context: DatadogContext, writer: Writer) { let initialSession = RUMSessionScope( isInitialSession: true, parent: self, startTime: context.sdkInitDate, + startPrecondition: startPrecondition, dependencies: dependencies, hasReplay: context.hasReplay ) + lastSessionEndReason = nil sessionScopes.append(initialSession) sessionScopeDidUpdate(initialSession) } - private func applicationStart(on command: RUMCommand, context: DatadogContext, writer: Writer) { - applicationActive = true - - guard context.applicationStateHistory.currentSnapshot.state != .background else { - return + /// Starts new RUM Session immediately after previous one expires or time outs. It transfers some of the state from the expired session to the new one. + private func refresh(expiredSession: RUMSessionScope, on command: RUMCommand, context: DatadogContext, writer: Writer) -> RUMSessionScope { + var startPrecondition: RUMSessionPrecondition? = nil + + if lastSessionEndReason == .timeOut { + startPrecondition = .inactivityTimeout + } else if lastSessionEndReason == .maxDuration { + startPrecondition = .maxDuration + } else { + dependencies.telemetry.error("Failed to determine session precondition for REFRESHED session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown"))") } - // Immediately start the ApplicationLaunchView for the new session - _ = process( - command: RUMApplicationStartCommand( - time: command.time, - attributes: command.attributes - ), - context: context, - writer: writer + let refreshedSession = RUMSessionScope( + from: expiredSession, + startTime: command.time, + startPrecondition: startPrecondition, + context: context ) + sessionScopeDidUpdate(refreshedSession) + lastSessionEndReason = nil + _ = refreshedSession.process(command: command, context: context, writer: writer) + return refreshedSession } + /// Starts new RUM Session some time after previous one was ended with ``RUMMonitorProtocol.stopSession()`` API. It may re-activate the last view from previous session. private func startNewSession(on command: RUMCommand, context: DatadogContext, writer: Writer) { + var startPrecondition: RUMSessionPrecondition? = nil + + if lastSessionEndReason == .stopAPI { + startPrecondition = .explicitStop + } else { + dependencies.telemetry.error("Failed to determine session precondition for NEW session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown"))") + } + + if didCreateInitialSessionCount > 0 { // Sanity check + // We assume this is not an initial session in the app (such is started with `RUMSDKInitCommand`: + dependencies.telemetry.error("Starting NEW session on due to \(type(of: command)), but initial sesison never existed") + } + let resumingViewScope = command is RUMStartViewCommand ? nil : lastActiveView let newSession = RUMSessionScope( isInitialSession: false, parent: self, startTime: command.time, + startPrecondition: startPrecondition, dependencies: dependencies, hasReplay: context.hasReplay, resumingViewScope: resumingViewScope ) lastActiveView = nil + lastSessionEndReason = nil sessionScopes.append(newSession) sessionScopeDidUpdate(newSession) @@ -164,4 +226,26 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { let isDiscarded = !sessionScope.isSampled dependencies.onSessionStart?(sessionID, isDiscarded) } + + /// Forces the `ApplicationLaunchView` to be started. + /// Added as part of https://github.com/DataDog/dd-sdk-ios/pull/1290 to separate creation of first view + /// from creation of initial session due to receiving `RUMSDKInitCommand`. Starting from RUM-1649 the "application launch" view + /// is started on SDK init only when the app is launched by user with no prewarming. + private func startApplicationLaunchView(on command: RUMCommand, context: DatadogContext, writer: Writer) { + applicationActive = true + + guard context.applicationStateHistory.currentSnapshot.state != .background else { + return + } + + // Immediately start the ApplicationLaunchView for the new session + _ = process( + command: RUMApplicationStartCommand( + time: command.time, + attributes: command.attributes + ), + context: context, + writer: writer + ) + } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift index 688726f968..fd350ff75a 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift @@ -152,10 +152,16 @@ internal class RUMResourceScope: RUMScope { let resourceEvent = RUMResourceEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init( + sessionReplaySampleRate: nil, + sessionSampleRate: Double(dependencies.sessionSampler.samplingRate) + ), discarded: nil, rulePsr: traceSamplingRate, - session: .init(plan: .plan1), + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ), spanId: spanId, traceId: traceId ), @@ -163,9 +169,11 @@ internal class RUMResourceScope: RUMScope { .init(id: .string(value: rumUUID.toRUMDataFormat)) }, application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: attributes), date: resourceStartTime.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -222,10 +230,10 @@ internal class RUMResourceScope: RUMScope { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( @@ -249,15 +257,20 @@ internal class RUMResourceScope: RUMScope { dd: .init( browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), action: self.context.activeUserActionID.map { rumUUID in .init(id: .string(value: rumUUID.toRUMDataFormat)) }, application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: attributes), date: command.time.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -284,10 +297,10 @@ internal class RUMResourceScope: RUMScope { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index a051cf67f3..b698b06279 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -37,10 +37,21 @@ internal struct RUMScopeDependencies { let rumUUIDGenerator: RUMUUIDGenerator /// Integration with CIApp tests. It contains the CIApp test context when active. let ciTest: RUMCITest? + let syntheticsTest: RUMSyntheticsTest? let vitalsReaders: VitalsReaders? let onSessionStart: RUM.SessionListener? var telemetry: Telemetry { core?.telemetry ?? NOPTelemetry() } + + var sessionType: RUMSessionType { + if ciTest != nil { + return .ciTest + } else if syntheticsTest != nil { + return .synthetics + } else { + return .user + } + } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 40c3567342..d6013687b9 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -15,6 +15,18 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { static let sessionMaxDuration: TimeInterval = 4 * 60 * 60 // 4 hours } + /// The reason of ending a session. + enum EndReason: String { + /// The session timed out because it received no interaction for x minutes. + /// See: ``Constants.sessionTimeoutDuration``. + case timeOut + /// The session expired because it exceeded max duration. + /// See: ``Constants.sessionMaxDuration``. + case maxDuration + /// The session was ended manually with ``RUMMonitorProtocol.stopSession()`` API. + case stopAPI + } + // MARK: - Child Scopes /// Active View scopes. Scopes are added / removed when the View starts / stops displaying. @@ -48,21 +60,27 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { /// This Session UUID. Equals `.nullUUID` if the Session is sampled. let sessionUUID: RUMUUID + /// The precondition that led to the creation of this session. + /// TODO: RUM-1650 This should become non-optional after all preconditions are implemented. + let startPrecondition: RUMSessionPrecondition? /// If events from this session should be sampled (send to Datadog). let isSampled: Bool - /// If the session is currently active. Set to false on a StopSession command - var isActive: Bool + /// 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). let isInitialSession: Bool /// The start time of this Session, measured in device date. In initial session this is the time of SDK init. private let sessionStartTime: Date /// Time of the last RUM interaction noticed by this Session. private var lastInteractionTime: Date + /// The reason why this session has ended or `nil` if it is still active. + private(set) var endReason: EndReason? init( isInitialSession: Bool, parent: RUMContextProvider, startTime: Date, + startPrecondition: RUMSessionPrecondition?, dependencies: RUMScopeDependencies, hasReplay: Bool?, resumingViewScope: RUMViewScope? = nil @@ -70,12 +88,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { self.parent = parent self.dependencies = dependencies self.isSampled = dependencies.sessionSampler.sample() + self.startPrecondition = startPrecondition self.sessionUUID = isSampled ? dependencies.rumUUIDGenerator.generateUnique() : .nullUUID self.isInitialSession = isInitialSession self.sessionStartTime = startTime self.lastInteractionTime = startTime self.trackBackgroundEvents = dependencies.trackBackgroundEvents - self.isActive = true + self.endReason = nil self.state = RUMSessionState( sessionUUID: sessionUUID.rawValue, isInitialSession: isInitialSession, @@ -83,7 +102,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { didStartWithReplay: hasReplay ) - if let viewScope = resumingViewScope, viewScope.identity.exists { + if let viewScope = resumingViewScope { viewScopes.append( RUMViewScope( isInitialView: false, @@ -108,21 +127,20 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { convenience init( from expiredSession: RUMSessionScope, startTime: Date, + startPrecondition: RUMSessionPrecondition?, context: DatadogContext ) { self.init( isInitialSession: false, parent: expiredSession.parent, startTime: startTime, + startPrecondition: startPrecondition, dependencies: expiredSession.dependencies, hasReplay: context.hasReplay ) // Transfer active Views by creating new `RUMViewScopes` for their identity objects: self.viewScopes = expiredSession.viewScopes.compactMap { expiredView in - guard expiredView.identity.exists else { - return nil // if the underlying identifiable (`UIVIewController`) no longer exists, skip transferring its scope - } return RUMViewScope( isInitialView: false, parent: self, @@ -144,15 +162,22 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { var context = parent.context context.sessionID = sessionUUID context.isSessionActive = isActive + context.sessionPrecondition = startPrecondition return context } // MARK: - RUMScope func process(command: RUMCommand, context: DatadogContext, writer: Writer) -> Bool { - if timedOutOrExpired(currentTime: command.time) { - return false // no longer keep this session + if hasTimedOut(currentTime: command.time) { + endReason = .timeOut + return false // end this session (no longer keep the session scope) + } + if hasExpired(currentTime: command.time) { + endReason = .maxDuration + return false // end this session (no longer keep the session scope) } + if command.isUserInteraction { lastInteractionTime = command.time } @@ -160,16 +185,17 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { if !isSampled { // Make sure sessions end even if they are sampled if command is RUMStopSessionCommand { - isActive = false + endReason = .stopAPI + return false // end this session (no longer keep the session scope) } - return isActive // discard all events in this session + return true // keep this session until it gets ended by any `endReason` } var deactivating = false if isActive { if command is RUMStopSessionCommand { - isActive = false + endReason = .stopAPI deactivating = true } else if let startApplicationCommand = command as? RUMApplicationStartCommand { startApplicationLaunchView(on: startApplicationCommand, context: context, writer: writer) @@ -223,8 +249,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { private func startApplicationLaunchView(on command: RUMApplicationStartCommand, context: DatadogContext, writer: Writer) { var startTime = sessionStartTime - if context.launchTime?.isActivePrewarm == false, - let processStartTime = context.launchTime?.launchDate { + if context.launchTime?.isActivePrewarm == false, let processStartTime = context.launchTime?.launchDate { startTime = processStartTime } @@ -232,7 +257,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { isInitialView: true, parent: self, dependencies: dependencies, - identity: RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL.asRUMViewIdentity(), + identity: ViewIdentifier(RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL), path: RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL, name: RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewName, attributes: command.attributes, @@ -277,7 +302,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { isInitialView: isStartingInitialView, parent: self, dependencies: dependencies, - identity: RUMOffViewEventsHandlingRule.Constants.backgroundViewURL.asRUMViewIdentity(), + identity: ViewIdentifier(RUMOffViewEventsHandlingRule.Constants.backgroundViewURL), path: RUMOffViewEventsHandlingRule.Constants.backgroundViewURL, name: RUMOffViewEventsHandlingRule.Constants.backgroundViewName, attributes: command.attributes, @@ -288,13 +313,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { ) } - private func timedOutOrExpired(currentTime: Date) -> Bool { + private func hasTimedOut(currentTime: Date) -> Bool { let timeElapsedSinceLastInteraction = currentTime.timeIntervalSince(lastInteractionTime) - let timedOut = timeElapsedSinceLastInteraction >= Constants.sessionTimeoutDuration + return timeElapsedSinceLastInteraction >= Constants.sessionTimeoutDuration + } + private func hasExpired(currentTime: Date) -> Bool { let sessionDuration = currentTime.timeIntervalSince(sessionStartTime) - let expired = sessionDuration >= Constants.sessionMaxDuration - - return timedOut || expired + return sessionDuration >= Constants.sessionMaxDuration } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift index c982c8da03..87be30bcce 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift @@ -49,9 +49,9 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { /// Number of Resources started during this User Action's lifespan. private var resourcesCount: UInt = 0 - /// Number of Errors occured during this User Action's lifespan. + /// Number of Errors occurred during this User Action's lifespan. private var errorsCount: UInt = 0 - /// Number of Long Tasks occured during this User Action's lifespan. + /// Number of Long Tasks occurred during this User Action's lifespan. private var longTasksCount: Int64 = 0 /// Number of Resources that started but not yet ended during this User Action's lifespan. private var activeResourcesCount: Int = 0 @@ -142,7 +142,10 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { action: nil, browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), action: .init( crash: .init(count: 0), @@ -156,9 +159,11 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { type: actionType.toRUMDataFormat ), application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: attributes), date: actionStartTime.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -168,10 +173,10 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index cc1c992442..d0220913d5 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -30,7 +30,7 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { private let isInitialView: Bool /// The value holding stable identity of this RUM View. - let identity: RUMViewIdentity + let identity: ViewIdentifier /// View attributes. private(set) var attributes: [AttributeKey: AttributeValue] /// View custom timings, keyed by name. The value of timing is given in nanoseconds. @@ -96,7 +96,7 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { isInitialView: Bool, parent: RUMContextProvider, dependencies: RUMScopeDependencies, - identity: RUMViewIdentity, + identity: ViewIdentifier, path: String, name: String, attributes: [AttributeKey: AttributeValue], @@ -171,7 +171,7 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { needsViewUpdate = true // View commands - case let command as RUMStartViewCommand where identity.equals(command.identity): + case let command as RUMStartViewCommand where identity == command.identity: if didReceiveStartCommand { // This is the case of duplicated "start" command. We know that the Session scope has created another instance of // the `RUMViewScope` for tracking this View, so we mark this one as inactive. @@ -179,13 +179,13 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { } didReceiveStartCommand = true needsViewUpdate = true - case let command as RUMStartViewCommand where !identity.equals(command.identity) && isActiveView: + case let command as RUMStartViewCommand where identity != command.identity && isActiveView: // This gets effective in case when the user didn't end the view explicitly. // If the view is flagged as "active" but another view is started, we know it needs to be // deactivated. This is achieved by setting `isActiveView` to `false` and sending one more view update. isActiveView = false needsViewUpdate = true - case let command as RUMStopViewCommand where identity.equals(command.identity): + case let command as RUMStopViewCommand where identity == command.identity: isActiveView = false needsViewUpdate = true case let command as RUMAddViewTimingCommand where isActiveView: @@ -372,7 +372,10 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { action: nil, browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), action: .init( crash: .init(count: 0), @@ -386,9 +389,11 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { type: .applicationStart ), application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: attributes), date: viewStartTime.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -398,10 +403,10 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( @@ -442,7 +447,11 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { let viewEvent = RUMViewEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init( + sessionReplaySampleRate: nil, + sessionSampleRate: Double(dependencies.sessionSampler.samplingRate), + startSessionReplayRecordingManually: nil + ), documentVersion: version.toInt64, pageStates: nil, replayStats: .init( @@ -450,12 +459,17 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { segmentsCount: nil, segmentsTotalRawSize: nil ), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: attributes), date: viewStartTime.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -469,11 +483,10 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { id: self.context.sessionID.toRUMDataFormat, isActive: self.context.isSessionActive, sampledForReplay: nil, - startPrecondition: nil, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( @@ -547,15 +560,20 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { dd: .init( browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), action: self.context.activeUserActionID.map { rumUUID in .init(id: .string(value: rumUUID.toRUMDataFormat)) }, application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: command.attributes), date: command.time.addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -579,10 +597,10 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( @@ -611,15 +629,20 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { browserSdkVersion: nil, configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), discarded: nil, - session: .init(plan: .plan1) + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) ), action: self.context.activeUserActionID.map { .init(id: .string(value: $0.toRUMDataFormat)) }, application: .init(id: self.context.rumApplicationID), + buildId: context.buildId, buildVersion: context.buildNumber, ciTest: dependencies.ciTest, connectivity: .init(context: context), + container: nil, context: .init(contextInfo: command.attributes), date: (command.time - command.duration).addingTimeInterval(serverTimeOffset).timeIntervalSince1970.toInt64Milliseconds, device: .init(context: context, telemetry: dependencies.telemetry), @@ -630,10 +653,10 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { session: .init( hasReplay: context.hasReplay, id: self.context.sessionID.toRUMDataFormat, - type: dependencies.ciTest != nil ? .ciTest : .user + type: dependencies.sessionType ), source: .init(rawValue: context.source) ?? .ios, - synthetics: nil, + synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, view: .init( diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMViewIdentity.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMViewIdentity.swift deleted file mode 100644 index 6a169030fc..0000000000 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMViewIdentity.swift +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import UIKit - -/// A type providing stable identity for a RUM View. -/// Based on the `equals(_:)` implementation, it decides if two `RUMViewIdentifiables` identify the same -/// RUM View or not. Each implementation of the `RUMViewIdentifiable` decides by its own if it should use -/// reference or value semantic for the comparison. -fileprivate protocol RUMViewIdentifiable { - /// Compares the instance of this identifiable with another `RUMViewIdentifiable`. - /// It returns `true` if both identify the same RUM View and `false` otherwise. - func equals(_ otherIdentifiable: RUMViewIdentifiable?) -> Bool - - /// Converts the instance of this type to `RUMViewIdentity`. - func asRUMViewIdentity() -> RUMViewIdentity - - /// If the RUM View's path name is not given explicitly by the user, each implementation of the `RUMViewIdentifiable` - /// must return a default path name. - var defaultViewPath: String { get } -} - -// MARK: - Supported `RUMViewIdentifiables` - -/// Extends `UIViewController` with the ability to identify the RUM View. -extension UIViewController: RUMViewIdentifiable { - fileprivate func equals(_ otherIdentifiable: RUMViewIdentifiable?) -> Bool { - if let otherViewController = otherIdentifiable as? UIViewController { - // Two `UIViewController` identifiables indicate the same RUM View only if their references are equal. - return self === otherViewController - } else { - return false - } - } - - func asRUMViewIdentity() -> RUMViewIdentity { - return RUMViewIdentity(object: self) - } - - var defaultViewPath: String { - return canonicalClassName - } -} - -/// Extends `String` with the ability to identify the RUM View. -extension String: RUMViewIdentifiable { - fileprivate func equals(_ otherIdentifiable: RUMViewIdentifiable?) -> Bool { - if let otherString = otherIdentifiable as? String { - // Two `String` identifiables indicate the same RUM View only if their values are equal. - return self == otherString - } else { - return false - } - } - - func asRUMViewIdentity() -> RUMViewIdentity { - return RUMViewIdentity(value: self) - } - - var defaultViewPath: String { self } -} - -// MARK: - `RUMViewIdentity` - -/// Manages the `RUMViewIdentifiable` by using either reference or value semantic. -internal struct RUMViewIdentity { - private weak var object: AnyObject? - private let value: Any? - - /// Initializes the `RUMViewIdentity` using reference semantic. - /// A weak reference to given `object` is stored internally. - fileprivate init(object: RUMViewIdentifiable) { - self.object = object as AnyObject - self.value = nil - } - - /// Initializes the `RUMViewIdentity` using value semantic. - /// A copy of the given `value` is stored internally. - fileprivate init(value: RUMViewIdentifiable) { - self.object = nil - self.value = value - } - - /// Returns `true` if a given identifiable indicates the same RUM View as the identifiable managed internally. - fileprivate func equals(_ identifiable: RUMViewIdentifiable?) -> Bool { - return self.identifiable?.equals(identifiable) ?? false - } - - /// Returns `true` if a given identity indicates the same RUM View as the identifiable managed internally. - func equals(_ identity: RUMViewIdentity?) -> Bool { - return equals(identity?.identifiable) - } - - /// Return default path name for the managed identifiable. - var defaultViewPath: String { - return identifiable?.defaultViewPath ?? "" - } - - /// Returns `true` if the managed identifiable is still available. - /// Underlying `identfiable` is stored as a weak reference, so it may become `nil` at any time. - /// For example when the `UIViewController` is deallocated. - var exists: Bool { - return identifiable != nil - } - - /// Returns the managed identifiable. - private var identifiable: RUMViewIdentifiable? { - return (object as? RUMViewIdentifiable) ?? (value as? RUMViewIdentifiable) - } -} diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/ViewIdentifier.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/ViewIdentifier.swift new file mode 100644 index 0000000000..35ce3346c0 --- /dev/null +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/ViewIdentifier.swift @@ -0,0 +1,30 @@ +/* + * 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 + +/// A unique identifier for a RUM view. +internal enum ViewIdentifier: Equatable { + case viewController(ObjectIdentifier) + case key(String) +} + +extension ViewIdentifier { + init(_ str: String) { + self = .key(str) + } +} + +#if canImport(UIKit) +import UIKit + +extension ViewIdentifier { + init(_ vc: UIViewController) { + self = .viewController(ObjectIdentifier(vc)) + } +} + +#endif diff --git a/DatadogRUM/Sources/RUMMonitorProtocol.swift b/DatadogRUM/Sources/RUMMonitorProtocol.swift index 702ec35e15..b56235f44b 100644 --- a/DatadogRUM/Sources/RUMMonitorProtocol.swift +++ b/DatadogRUM/Sources/RUMMonitorProtocol.swift @@ -53,6 +53,15 @@ public protocol RUMMonitorProtocol: AnyObject { // MARK: - session + /// Get the currently active session ID. Returns `nil` if no sessions are currently active or if + /// the current session is sampled out. + /// This method uses an asynchronous callback to ensure all pending RUM events have been processed + /// up to the moment of the call. + /// - Parameters: + /// - completion: the callback that will recieve the current session ID. This will be called from a + /// background thread + func currentSessionID(completion: @escaping (String?) -> Void) + /// Stops the current RUM session. /// A new session will start in response to a call to `startView` or `addAction`. /// If the session is started because of a call to `addAction`, the last known view is restarted in the new session. @@ -321,6 +330,7 @@ internal class NOPMonitor: RUMMonitorProtocol { ) } + func currentSessionID(completion: (String?) -> Void) { completion(nil) } func addAttribute(forKey key: AttributeKey, value: AttributeValue) { warn() } func removeAttribute(forKey key: AttributeKey) { warn() } func stopSession() { warn() } diff --git a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift index 9f840a54f3..ea6471f8d9 100644 --- a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift +++ b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift @@ -24,8 +24,8 @@ class RUMInstrumentationTests: XCTestCase { // Then withExtendedLifetime(instrumentation) { DDAssertActiveSwizzlings([ - "UIViewController.viewDidAppear:", - "UIViewController.viewDidDisappear:", + "viewDidAppear:", + "viewDidDisappear:", ]) XCTAssertNil(instrumentation.longTasks) } @@ -42,7 +42,7 @@ class RUMInstrumentationTests: XCTestCase { // Then withExtendedLifetime(instrumentation) { - DDAssertActiveSwizzlings(["UIApplication.sendEvent:"]) + DDAssertActiveSwizzlings(["sendEvent:"]) XCTAssertNil(instrumentation.longTasks) } } @@ -105,7 +105,7 @@ class RUMInstrumentationTests: XCTestCase { internal func DDAssertActiveSwizzlings(_ expectedSwizzledSelectors: [String], file: StaticString = #filePath, line: UInt = #line) { _DDEvaluateAssertion(message: "Only \(expectedSwizzledSelectors) swizzlings should be active", file: file, line: line) { - let actual = Swizzling.activeSwizzlingNames.sorted() + let actual = Swizzling.methods.map { "\(method_getName($0))" }.sorted() let expected = expectedSwizzledSelectors.sorted() guard actual == expected else { diff --git a/DatadogRUM/Tests/Instrumentation/Views/RUMViewsHandlerTests.swift b/DatadogRUM/Tests/Instrumentation/Views/RUMViewsHandlerTests.swift index 7548e197b4..f40a875664 100644 --- a/DatadogRUM/Tests/Instrumentation/Views/RUMViewsHandlerTests.swift +++ b/DatadogRUM/Tests/Instrumentation/Views/RUMViewsHandlerTests.swift @@ -41,7 +41,7 @@ class RUMViewsHandlerTests: XCTestCase { XCTAssertEqual(commandSubscriber.receivedCommands.count, 1) let command = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) - XCTAssertTrue(command.identity.equals(view)) + XCTAssertTrue(command.identity == ViewIdentifier(view)) XCTAssertEqual(command.path, viewControllerClassName) XCTAssertEqual(command.name, viewName) XCTAssertEqual(command.attributes as? [String: String], ["foo": "bar"]) @@ -68,11 +68,11 @@ class RUMViewsHandlerTests: XCTestCase { let startCommand1 = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand1.identity.equals(view1)) + XCTAssertTrue(startCommand1.identity == ViewIdentifier(view1)) XCTAssertEqual(startCommand1.attributes as? [String: String], ["key1": "val1"]) - XCTAssertTrue(stopCommand.identity.equals(view1)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(view1)) XCTAssertEqual(stopCommand.attributes.count, 0) - XCTAssertTrue(startCommand2.identity.equals(view2)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(view2)) XCTAssertEqual(startCommand2.attributes as? [String: String], ["key2": "val2"]) } @@ -126,11 +126,11 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[3] as? RUMStopViewCommand) let startCommand3 = try XCTUnwrap(commandSubscriber.receivedCommands[4] as? RUMStartViewCommand) - XCTAssertTrue(startCommand1.identity.equals(view1)) - XCTAssertTrue(stopCommand1.identity.equals(view1)) - XCTAssertTrue(startCommand2.identity.equals(view2)) - XCTAssertTrue(stopCommand2.identity.equals(view2)) - XCTAssertTrue(startCommand3.identity.equals(view1)) + XCTAssertTrue(startCommand1.identity == ViewIdentifier(view1)) + XCTAssertTrue(stopCommand1.identity == ViewIdentifier(view1)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(view2)) + XCTAssertTrue(stopCommand2.identity == ViewIdentifier(view2)) + XCTAssertTrue(startCommand3.identity == ViewIdentifier(view1)) } func testGivenAcceptingPredicate_whenViewDidDisappearButPreviousView_itDoesNotStartAnyRUMView() { @@ -180,12 +180,12 @@ class RUMViewsHandlerTests: XCTestCase { XCTAssertEqual(commandSubscriber.receivedCommands.count, 3) let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) - XCTAssertTrue(stopCommand.identity.equals(view)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(view)) XCTAssertEqual(stopCommand.attributes.count, 0) XCTAssertEqual(stopCommand.time, .mockDecember15th2019At10AMUTC()) let startCommand = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand.identity.equals(view)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(view)) XCTAssertEqual(startCommand.path, viewControllerClassName) XCTAssertEqual(startCommand.name, viewName) XCTAssertEqual(startCommand.attributes as? [String: String], ["foo": "bar"]) @@ -269,8 +269,8 @@ class RUMViewsHandlerTests: XCTestCase { let startCommand = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) - XCTAssertTrue(startCommand.identity.equals(someView)) - XCTAssertTrue(stopCommand.identity.equals(someView)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(someView)) } func testGivenUntrackedModal_whenTransitioningToAppearedView_viewDoesStart() throws { @@ -306,9 +306,9 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand.identity.equals(someView)) - XCTAssertTrue(stopCommand.identity.equals(someView)) - XCTAssertTrue(startCommand2.identity.equals(someView)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(someView)) } func testGiveniOS13AppearedView_whenTransitioningToModal_viewDoesStop() throws { @@ -344,8 +344,8 @@ class RUMViewsHandlerTests: XCTestCase { let startCommand = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) - XCTAssertTrue(startCommand.identity.equals(someView)) - XCTAssertTrue(stopCommand.identity.equals(someView)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(someView)) } } @@ -386,9 +386,9 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand.identity.equals(someView)) - XCTAssertTrue(stopCommand.identity.equals(someView)) - XCTAssertTrue(startCommand2.identity.equals(someView)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(someView)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(someView)) } } @@ -414,7 +414,7 @@ class RUMViewsHandlerTests: XCTestCase { let command = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) XCTAssertEqual(command.time, .mockDecember15th2019At10AMUTC()) - XCTAssertTrue(command.identity.equals(viewIdentity)) + XCTAssertTrue(command.identity == ViewIdentifier(viewIdentity)) XCTAssertEqual(command.name, viewName) XCTAssertEqual(command.path, viewPath) DDAssertDictionariesEqual(command.attributes, viewAttributes) @@ -454,11 +454,11 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand1.identity.equals(view1Identity)) + XCTAssertTrue(startCommand1.identity == ViewIdentifier(view1Identity)) DDAssertDictionariesEqual(startCommand1.attributes, view1Attributes) - XCTAssertTrue(stopCommand.identity.equals(view1Identity)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(view1Identity)) XCTAssertEqual(stopCommand.attributes.count, 0) - XCTAssertTrue(startCommand2.identity.equals(view2Identity)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(view2Identity)) DDAssertDictionariesEqual(startCommand2.attributes, view2Attributes) } @@ -525,9 +525,9 @@ class RUMViewsHandlerTests: XCTestCase { let startCommand = try XCTUnwrap(commandSubscriber.receivedCommands[0] as? RUMStartViewCommand) let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) - XCTAssertTrue(startCommand.identity.equals(viewIdentity)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(viewIdentity)) DDAssertDictionariesEqual(startCommand.attributes, viewAttributes) - XCTAssertTrue(stopCommand.identity.equals(viewIdentity)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(viewIdentity)) XCTAssertEqual(stopCommand.attributes.count, 0) } @@ -567,10 +567,10 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand1 = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(startCommand1.identity.equals(view1Identity)) + XCTAssertTrue(startCommand1.identity == ViewIdentifier(view1Identity)) DDAssertDictionariesEqual(startCommand1.attributes, view1Attributes) - XCTAssertTrue(stopCommand1.identity.equals(view1Identity)) - XCTAssertTrue(startCommand2.identity.equals(view2Identity)) + XCTAssertTrue(stopCommand1.identity == ViewIdentifier(view1Identity)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(view2Identity)) DDAssertDictionariesEqual(startCommand2.attributes, view2Attributes) } @@ -612,13 +612,13 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand2 = try XCTUnwrap(commandSubscriber.receivedCommands[3] as? RUMStopViewCommand) let startCommand3 = try XCTUnwrap(commandSubscriber.receivedCommands[4] as? RUMStartViewCommand) - XCTAssertTrue(startCommand1.identity.equals(view1Identity)) + XCTAssertTrue(startCommand1.identity == ViewIdentifier(view1Identity)) DDAssertDictionariesEqual(startCommand1.attributes, view1Attributes) - XCTAssertTrue(stopCommand1.identity.equals(view1Identity)) - XCTAssertTrue(startCommand2.identity.equals(view2Identity)) + XCTAssertTrue(stopCommand1.identity == ViewIdentifier(view1Identity)) + XCTAssertTrue(startCommand2.identity == ViewIdentifier(view2Identity)) DDAssertDictionariesEqual(startCommand2.attributes, view2Attributes) - XCTAssertTrue(stopCommand2.identity.equals(view2Identity)) - XCTAssertTrue(startCommand3.identity.equals(view1Identity)) + XCTAssertTrue(stopCommand2.identity == ViewIdentifier(view2Identity)) + XCTAssertTrue(startCommand3.identity == ViewIdentifier(view1Identity)) DDAssertDictionariesEqual(startCommand1.attributes, view1Attributes) } @@ -648,10 +648,10 @@ class RUMViewsHandlerTests: XCTestCase { let stopCommand = try XCTUnwrap(commandSubscriber.receivedCommands[1] as? RUMStopViewCommand) let startCommand = try XCTUnwrap(commandSubscriber.receivedCommands[2] as? RUMStartViewCommand) - XCTAssertTrue(stopCommand.identity.equals(viewIdentity)) + XCTAssertTrue(stopCommand.identity == ViewIdentifier(viewIdentity)) XCTAssertEqual(stopCommand.attributes.count, 0) XCTAssertEqual(stopCommand.time, .mockDecember15th2019At10AMUTC()) - XCTAssertTrue(startCommand.identity.equals(viewIdentity)) + XCTAssertTrue(startCommand.identity == ViewIdentifier(viewIdentity)) XCTAssertEqual(startCommand.path, viewPath) XCTAssertEqual(startCommand.name, viewName) DDAssertDictionariesEqual(startCommand.attributes, viewAttributes) diff --git a/DatadogRUM/Tests/Integrations/TelemetryReceiverTests.swift b/DatadogRUM/Tests/Integrations/TelemetryReceiverTests.swift index bc70c89dd6..e83108d1a4 100644 --- a/DatadogRUM/Tests/Integrations/TelemetryReceiverTests.swift +++ b/DatadogRUM/Tests/Integrations/TelemetryReceiverTests.swift @@ -291,6 +291,8 @@ class TelemetryReceiverTests: XCTestCase { ) ) + let backgroundTasksEnabled: Bool? = .mockRandom() + let batchProcessingLevel: Int64? = .mockRandom() let batchSize: Int64? = .mockRandom() let batchUploadFrequency: Int64? = .mockRandom() let dartVersion: String? = .mockRandom() @@ -316,6 +318,8 @@ class TelemetryReceiverTests: XCTestCase { // When core.telemetry.configuration( + backgroundTasksEnabled: backgroundTasksEnabled, + batchProcessingLevel: batchProcessingLevel, batchSize: batchSize, batchUploadFrequency: batchUploadFrequency, dartVersion: dartVersion, @@ -346,6 +350,8 @@ class TelemetryReceiverTests: XCTestCase { XCTAssertEqual(event?.version, core.context.sdkVersion) XCTAssertEqual(event?.service, "dd-sdk-ios") XCTAssertEqual(event?.source.rawValue, core.context.source) + XCTAssertEqual(event?.telemetry.configuration.backgroundTasksEnabled, backgroundTasksEnabled) + XCTAssertEqual(event?.telemetry.configuration.batchProcessingLevel, batchProcessingLevel) XCTAssertEqual(event?.telemetry.configuration.batchSize, batchSize) XCTAssertEqual(event?.telemetry.configuration.batchUploadFrequency, batchUploadFrequency) XCTAssertEqual(event?.telemetry.configuration.dartVersion, dartVersion) diff --git a/DatadogRUM/Tests/Mocks/RUMDataModelMocks.swift b/DatadogRUM/Tests/Mocks/RUMDataModelMocks.swift index 11cc8d1199..13411d04a4 100644 --- a/DatadogRUM/Tests/Mocks/RUMDataModelMocks.swift +++ b/DatadogRUM/Tests/Mocks/RUMDataModelMocks.swift @@ -51,6 +51,12 @@ extension RUMMethod: RandomMockable { } } +extension RUMSessionPrecondition: RandomMockable { + public static func mockRandom() -> RUMSessionPrecondition { + return [.userAppLaunch, .inactivityTimeout, .maxDuration, .backgroundLaunch, .prewarm, .fromNonInteractiveSession, .explicitStop].randomElement()! + } +} + extension RUMEventAttributes: RandomMockable { public static func mockRandom() -> RUMEventAttributes { return .init(contextInfo: mockRandomAttributes()) @@ -109,7 +115,11 @@ extension RUMOperatingSystem: RandomMockable { extension RUMViewEvent.DD.Configuration: RandomMockable { public static func mockRandom() -> RUMViewEvent.DD.Configuration { - return .init(sessionReplaySampleRate: .mockRandom(min: 0, max: 100), sessionSampleRate: .mockRandom(min: 0, max: 100)) + return .init( + sessionReplaySampleRate: .mockRandom(min: 0, max: 100), + sessionSampleRate: .mockRandom(min: 0, max: 100), + startSessionReplayRecordingManually: nil + ) } } @@ -131,12 +141,17 @@ extension RUMViewEvent: RandomMockable { documentVersion: .mockRandom(), pageStates: nil, replayStats: nil, - session: .init(plan: [.plan1, .plan2].randomElement()!) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -149,7 +164,6 @@ extension RUMViewEvent: RandomMockable { id: .mockRandom(), isActive: true, sampledForReplay: nil, - startPrecondition: .appLaunch, type: .user ), source: .ios, @@ -223,15 +237,20 @@ extension RUMResourceEvent: RandomMockable { configuration: .mockRandom(), discarded: nil, rulePsr: nil, - session: .init(plan: [.plan1, .plan2].randomElement()!), + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ), spanId: .mockRandom(), traceId: .mockRandom() ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -296,7 +315,10 @@ extension RUMActionEvent: RandomMockable { ), browserSdkVersion: nil, configuration: .mockRandom(), - session: .init(plan: [.plan1, .plan2].randomElement()!) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init( crash: .init(count: .mockRandom()), @@ -310,9 +332,11 @@ extension RUMActionEvent: RandomMockable { type: [.tap, .swipe, .scroll].randomElement()! ), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -356,13 +380,18 @@ extension RUMErrorEvent: RandomMockable { dd: .init( browserSdkVersion: nil, configuration: .mockRandom(), - session: .init(plan: [.plan1, .plan2].randomElement()!) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -435,13 +464,18 @@ extension RUMLongTaskEvent: RandomMockable { browserSdkVersion: nil, configuration: .mockRandom(), discarded: nil, - session: .init(plan: [.plan1, .plan2].randomElement()!) + session: .init( + plan: [.plan1, .plan2].randomElement()!, + sessionPrecondition: .mockRandom() + ) ), action: .init(id: .mockRandom()), application: .init(id: .mockRandom()), + buildId: nil, buildVersion: .mockRandom(), ciTest: nil, connectivity: .mockRandom(), + container: nil, context: .mockRandom(), date: .mockRandom(), device: .mockRandom(), @@ -479,9 +513,11 @@ extension TelemetryConfigurationEvent: RandomMockable { actionNameAttribute: nil, allowFallbackToLocalStorage: nil, allowUntrustedEvents: nil, + backgroundTasksEnabled: .mockRandom(), + batchProcessingLevel: .mockRandom(), batchSize: .mockAny(), - batchUploadFrequency: .mockAny(), - defaultPrivacyLevel: .mockAny(), + batchUploadFrequency: .mockRandom(), + defaultPrivacyLevel: .mockRandom(), forwardConsoleLogs: nil, forwardErrorsToLogs: nil, forwardReports: nil, diff --git a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift index 2f6887809c..0435472a1a 100644 --- a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift +++ b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift @@ -37,6 +37,7 @@ extension CrashReportReceiver: AnyMockable { trackBackgroundEvents: Bool = true, uuidGenerator: RUMUUIDGenerator = DefaultRUMUUIDGenerator(), ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, telemetry: Telemetry = NOPTelemetry() ) -> Self { .init( @@ -46,6 +47,7 @@ extension CrashReportReceiver: AnyMockable { trackBackgroundEvents: trackBackgroundEvents, uuidGenerator: uuidGenerator, ciTest: ciTest, + syntheticsTest: syntheticsTest, telemetry: telemetry ) } @@ -123,6 +125,19 @@ extension RUMEventsMapper { // MARK: - RUMCommand Mocks +///// Holds the `mockView` object so it can be weakly referenced by `RUMViewScope` mocks. +let mockView: UIViewController = createMockViewInWindow() + +extension ViewIdentifier { + static func mockViewIdentifier() -> ViewIdentifier { + ViewIdentifier(mockView) + } + + static func mockRandomString() -> ViewIdentifier { + ViewIdentifier(String.mockRandom()) + } +} + struct RUMCommandMock: RUMCommand { var time = Date() var attributes: [AttributeKey: AttributeValue] = [:] @@ -158,6 +173,29 @@ extension RUMCommand { } } +extension RUMApplicationStartCommand: AnyMockable, RandomMockable { + public static func mockAny() -> RUMApplicationStartCommand { mockWith() } + + public static func mockRandom() -> RUMApplicationStartCommand { + return .mockWith( + time: .mockRandomInThePast(), + attributes: mockRandomAttributes() + ) + } + + static func mockWith( + time: Date = Date(), + attributes: [AttributeKey: AttributeValue] = [:] + ) -> RUMApplicationStartCommand { + return RUMApplicationStartCommand( + time: time, + attributes: attributes, + canStartBackgroundView: false, + isUserInteraction: false + ) + } +} + extension RUMStartViewCommand: AnyMockable, RandomMockable { public static func mockAny() -> RUMStartViewCommand { mockWith() } @@ -165,7 +203,7 @@ extension RUMStartViewCommand: AnyMockable, RandomMockable { return .mockWith( time: .mockRandomInThePast(), attributes: mockRandomAttributes(), - identity: String.mockRandom().asRUMViewIdentity(), + identity: .mockRandomString(), name: .mockRandom(), path: .mockRandom() ) @@ -174,9 +212,9 @@ extension RUMStartViewCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], - identity: RUMViewIdentity = mockViewIdentity, + identity: ViewIdentifier = .mockViewIdentifier(), name: String = .mockAny(), - path: String? = nil + path: String = .mockAny() ) -> RUMStartViewCommand { return RUMStartViewCommand( time: time, @@ -195,14 +233,14 @@ extension RUMStopViewCommand: AnyMockable, RandomMockable { return .mockWith( time: .mockRandomInThePast(), attributes: mockRandomAttributes(), - identity: String.mockRandom().asRUMViewIdentity() + identity: .mockRandomString() ) } static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], - identity: RUMViewIdentity = mockViewIdentity + identity: ViewIdentifier = .mockViewIdentifier() ) -> RUMStopViewCommand { return RUMStopViewCommand( time: time, attributes: attributes, identity: identity @@ -611,6 +649,7 @@ extension RUMContext { rumApplicationID: String = .mockAny(), sessionID: RUMUUID = .mockRandom(), isSessionActive: Bool = true, + sessionPrecondition: RUMSessionPrecondition? = .userAppLaunch, activeViewID: RUMUUID? = nil, activeViewPath: String? = nil, activeViewName: String? = nil, @@ -661,6 +700,7 @@ extension RUMScopeDependencies { eventBuilder: RUMEventBuilder = RUMEventBuilder(eventsMapper: .mockNoOp()), rumUUIDGenerator: RUMUUIDGenerator = DefaultRUMUUIDGenerator(), ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, vitalsReaders: VitalsReaders? = nil, onSessionStart: @escaping RUM.SessionListener = mockNoOpSessionListener() ) -> RUMScopeDependencies { @@ -674,6 +714,7 @@ extension RUMScopeDependencies { eventBuilder: eventBuilder, rumUUIDGenerator: rumUUIDGenerator, ciTest: ciTest, + syntheticsTest: syntheticsTest, vitalsReaders: vitalsReaders, onSessionStart: onSessionStart ) @@ -689,6 +730,7 @@ extension RUMScopeDependencies { eventBuilder: RUMEventBuilder? = nil, rumUUIDGenerator: RUMUUIDGenerator? = nil, ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil, vitalsReaders: VitalsReaders? = nil, onSessionStart: RUM.SessionListener? = nil ) -> RUMScopeDependencies { @@ -702,6 +744,7 @@ extension RUMScopeDependencies { eventBuilder: eventBuilder ?? self.eventBuilder, rumUUIDGenerator: rumUUIDGenerator ?? self.rumUUIDGenerator, ciTest: ciTest ?? self.ciTest, + syntheticsTest: syntheticsTest ?? self.syntheticsTest, vitalsReaders: vitalsReaders ?? self.vitalsReaders, onSessionStart: onSessionStart ?? self.onSessionStart ) @@ -724,6 +767,7 @@ extension RUMSessionScope { isInitialSession: Bool = .mockAny(), parent: RUMContextProvider = RUMContextProviderMock(), startTime: Date = .mockAny(), + startPrecondition: RUMSessionPrecondition? = .userAppLaunch, dependencies: RUMScopeDependencies = .mockAny(), hasReplay: Bool? = .mockAny() ) -> RUMSessionScope { @@ -731,6 +775,7 @@ extension RUMSessionScope { isInitialSession: isInitialSession, parent: parent, startTime: startTime, + startPrecondition: startPrecondition, dependencies: dependencies, hasReplay: hasReplay ) @@ -764,10 +809,6 @@ func createMockView(viewControllerClassName: String) -> UIViewController { return viewController } -///// Holds the `mockView` object so it can be weakly referenced by `RUMViewScope` mocks. -let mockView: UIViewController = createMockViewInWindow() -let mockViewIdentity: RUMViewIdentity = mockView.asRUMViewIdentity() - extension RUMViewScope { static func mockAny() -> RUMViewScope { return mockWith() @@ -783,7 +824,7 @@ extension RUMViewScope { isInitialView: Bool = false, parent: RUMContextProvider = RUMContextProviderMock(), dependencies: RUMScopeDependencies = .mockAny(), - identity: RUMViewIdentity = mockViewIdentity, + identity: ViewIdentifier = .mockViewIdentifier(), path: String = .mockAny(), name: String = .mockAny(), attributes: [AttributeKey: AttributeValue] = [:], diff --git a/DatadogRUM/Tests/RUMMonitor/MonitorTests.swift b/DatadogRUM/Tests/RUMMonitor/MonitorTests.swift index 88404c6b7c..c27c2bd32e 100644 --- a/DatadogRUM/Tests/RUMMonitor/MonitorTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/MonitorTests.swift @@ -60,6 +60,42 @@ class MonitorTests: XCTestCase { // Then XCTAssertNil(core.context.baggages[RUMFeature.name]) } + + func testStartView_withViewController_itUsesClassNameAsViewName() throws { + // Given + let vc = createMockView(viewControllerClassName: "SomeViewController") + + // When + let monitor = Monitor( + core: core, + dependencies: .mockWith(core: core, sessionSampler: .mockKeepAll()), + dateProvider: DateProviderMock() + ) + monitor.startView(viewController: vc) + monitor.flush() + + // Then + XCTAssertEqual(monitor.scopes.sessionScopes.first?.viewScopes.first?.viewName, "SomeViewController") + XCTAssertEqual(monitor.scopes.sessionScopes.first?.viewScopes.first?.viewPath, "SomeViewController") + } + + func testStartView_withViewController_itUsesClassNameAsViewPath() throws { + // Given + let vc = createMockView(viewControllerClassName: "SomeViewController") + + // When + let monitor = Monitor( + core: core, + dependencies: .mockWith(core: core, sessionSampler: .mockKeepAll()), + dateProvider: DateProviderMock() + ) + monitor.startView(viewController: vc, name: "Some View") + monitor.flush() + + // Then + XCTAssertEqual(monitor.scopes.sessionScopes.first?.viewScopes.first?.viewName, "Some View") + XCTAssertEqual(monitor.scopes.sessionScopes.first?.viewScopes.first?.viewPath, "SomeViewController") + } } // MARK: - Convenience diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift index 9d9df1f16a..aed552757a 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift @@ -10,11 +10,23 @@ import DatadogInternal @testable import DatadogRUM class RUMApplicationScopeTests: XCTestCase { - let context: DatadogContext = .mockAny() let writer = FileWriterMock() + /// Creates `RUMApplicationScope` instance and configures it with the effects applied when RUM gets enabled. + /// TODO: RUM-1649 Move this configuration to `RUMApplicationScope.init()`, so we can remove this setup in tests. + private func createRUMApplicationScope( + dependencies: RUMScopeDependencies, + sdkContext: DatadogContext = .mockWith(sdkInitDate: Date()) + ) -> RUMApplicationScope { + let scope = RUMApplicationScope(dependencies: dependencies) + // Always receive `RUMSDKInitCommand` as the very first command (see: `Monitor.notifySDKInit()`) + let initCommand = RUMSDKInitCommand(time: sdkContext.sdkInitDate) + _ = scope.process(command: initCommand, context: sdkContext, writer: writer) + return scope + } + func testRootContext() { - let scope = RUMApplicationScope( + let scope = createRUMApplicationScope( dependencies: .mockWith(rumApplicationID: "abc-123") ) @@ -25,7 +37,7 @@ class RUMApplicationScopeTests: XCTestCase { XCTAssertNil(scope.context.activeUserActionID) } - func testWhenFirstEventIsReceived_itStartsNewSession() throws { + func testWhenInitialized_itStartsNewSession() throws { let expectation = self.expectation(description: "onSessionStart is called") let onSessionStart: RUM.SessionListener = { sessionId, isDiscarded in XCTAssertTrue(sessionId.matches(regex: .uuidRegex)) @@ -33,25 +45,19 @@ class RUMApplicationScopeTests: XCTestCase { expectation.fulfill() } - // Given - let currentTime = Date() - let scope = RUMApplicationScope( + // When + let scope = createRUMApplicationScope( dependencies: .mockWith( sessionSampler: .mockRejectAll(), onSessionStart: onSessionStart ) ) - XCTAssertNil(scope.activeSession) - - // When - let command = mockRandomRUMCommand().replacing(time: currentTime.addingTimeInterval(1)) - XCTAssertTrue(scope.process(command: command, context: context, writer: writer)) waitForExpectations(timeout: 0.5) // Then - let sessionScope = try XCTUnwrap(scope.activeSession) - XCTAssertTrue(sessionScope.isInitialSession, "Starting the very first view in application must create initial session") + let session = try XCTUnwrap(scope.activeSession) + XCTAssertTrue(session.isInitialSession, "Starting the very first view in application must create initial session") } func testWhenSessionExpires_itStartsANewOneAndTransfersActiveViews() throws { @@ -66,7 +72,7 @@ class RUMApplicationScopeTests: XCTestCase { // Given var currentTime = Date() - let scope = RUMApplicationScope( + let scope = createRUMApplicationScope( dependencies: .mockWith( onSessionStart: onSessionStart ) @@ -75,8 +81,8 @@ class RUMApplicationScopeTests: XCTestCase { let view = createMockViewInWindow() _ = scope.process( - command: RUMStartViewCommand.mockWith(time: currentTime, identity: view.asRUMViewIdentity()), - context: context, + command: RUMStartViewCommand.mockWith(time: currentTime, identity: ViewIdentifier(view)), + context: .mockAny(), writer: writer ) @@ -87,7 +93,7 @@ class RUMApplicationScopeTests: XCTestCase { currentTime.addTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) _ = scope.process( command: RUMAddUserActionCommand.mockWith(time: currentTime), - context: context, + context: .mockAny(), writer: writer ) @@ -101,7 +107,7 @@ class RUMApplicationScopeTests: XCTestCase { let initialViewScope = try XCTUnwrap(initialSession.viewScopes.first) let transferredViewScope = try XCTUnwrap(nextSession.viewScopes.first) XCTAssertNotEqual(initialViewScope.viewUUID, transferredViewScope.viewUUID, "Transferred view scope must have different view id") - XCTAssertTrue(transferredViewScope.identity.equals(view), "Transferred view scope must track the same view") + XCTAssertTrue(transferredViewScope.identity == ViewIdentifier(view), "Transferred view scope must track the same view") XCTAssertFalse(nextSession.isInitialSession, "Any next session in the application must be marked as 'not initial'") } @@ -109,20 +115,20 @@ class RUMApplicationScopeTests: XCTestCase { func testWhenSamplingRateIs100_allEventsAreSent() { let currentTime = Date() - let scope = RUMApplicationScope( + let scope = createRUMApplicationScope( dependencies: .mockWith( sessionSampler: Sampler(samplingRate: 100) ) ) _ = scope.process( - command: RUMStartViewCommand.mockWith(time: currentTime, identity: mockViewIdentity), - context: context, + command: RUMStartViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: .mockAny(), writer: writer ) _ = scope.process( - command: RUMStopViewCommand.mockWith(time: currentTime, identity: mockViewIdentity), - context: context, + command: RUMStopViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: .mockAny(), writer: writer ) @@ -132,20 +138,20 @@ class RUMApplicationScopeTests: XCTestCase { func testWhenSamplingRateIs0_noEventsAreSent() { let currentTime = Date() - let scope = RUMApplicationScope( + let scope = createRUMApplicationScope( dependencies: .mockWith( sessionSampler: Sampler(samplingRate: 0) ) ) _ = scope.process( - command: RUMStartViewCommand.mockWith(time: currentTime, identity: mockViewIdentity), - context: context, + command: RUMStartViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: .mockAny(), writer: writer ) _ = scope.process( - command: RUMStartViewCommand.mockWith(time: currentTime, identity: mockViewIdentity), - context: context, + command: RUMStartViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: .mockAny(), writer: writer ) @@ -154,7 +160,7 @@ class RUMApplicationScopeTests: XCTestCase { func testWhenSamplingRateIs50_onlyHalfOfTheEventsAreSent() throws { var currentTime = Date() - let scope = RUMApplicationScope( + let scope = createRUMApplicationScope( dependencies: .mockWith( sessionSampler: Sampler(samplingRate: 50) ) @@ -163,13 +169,13 @@ class RUMApplicationScopeTests: XCTestCase { let simulatedSessionsCount = 400 (0.. Bool { - return equals(vc?.asRUMViewIdentity()) - } - - func equals(_ string: String?) -> Bool { - return equals(string?.asRUMViewIdentity()) - } -} diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/Utils/ViewIdentifierTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/Utils/ViewIdentifierTests.swift new file mode 100644 index 0000000000..529effab14 --- /dev/null +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/Utils/ViewIdentifierTests.swift @@ -0,0 +1,56 @@ +/* + * 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 UIKit +@testable import DatadogRUM + +class ViewIdentifierTests: XCTestCase { + // MARK: - Comparing identifiables + + func testGivenTwoUIViewControllers_whenComparingTheirRUMViewIdentity_itEqualsOnlyForTheSameInstance() { + // Given + let vc1 = createMockView(viewControllerClassName: .mockRandom(among: .alphanumerics)) + let vc2 = createMockView(viewControllerClassName: .mockRandom(among: .alphanumerics)) + + // When + let identity1 = ViewIdentifier(vc1) + let identity2 = ViewIdentifier(vc2) + + // Then + XCTAssertTrue(identity1 == ViewIdentifier(vc1)) + XCTAssertTrue(identity2 == ViewIdentifier(vc2)) + XCTAssertFalse(identity1 == identity2) + } + + func testGivenTwoStringKeys_whenComparingTheirRUMViewIdentity_itEqualsOnlyForTheSameInstance() { + // Given + let key1: String = .mockRandom() + let key2: String = .mockRandom() + + // When + let identity1 = ViewIdentifier(key1) + let identity2 = ViewIdentifier(key2) + + // Then + XCTAssertTrue(identity1 == ViewIdentifier(key1)) + XCTAssertTrue(identity2 == ViewIdentifier(key2)) + XCTAssertFalse(identity1 == identity2) + } + + func testGivenTwoRUMViewIdentitiesOfDifferentKind_whenComparing_theyDoNotEqual() { + // Given + let vc = createMockView(viewControllerClassName: .mockRandom(among: .alphanumerics)) + let key: String = .mockRandom() + + // When + let identity1 = ViewIdentifier(vc) + let identity2 = ViewIdentifier(key) + + // Then + XCTAssertFalse(identity1 == identity2) + } +} diff --git a/DatadogRUM/Tests/RUMTests.swift b/DatadogRUM/Tests/RUMTests.swift index 3a83068f7c..12a0f6a6be 100644 --- a/DatadogRUM/Tests/RUMTests.swift +++ b/DatadogRUM/Tests/RUMTests.swift @@ -384,7 +384,7 @@ class RUMTests: XCTestCase { XCTAssertNotNil(context) XCTAssertEqual(context?.applicationID, applicationID) XCTAssertEqual(context?.sessionID, sessionID.toRUMDataFormat) - XCTAssertNil(context?.viewID) + XCTAssertNotNil(context?.viewID) XCTAssertNil(context?.userActionID) } @@ -404,4 +404,48 @@ class RUMTests: XCTestCase { waitForExpectations(timeout: 2.5) } + + // MARK: RUM+Internal tests + func testWhenPassedNOPCore_lateEnableUrlSessionTrackingThrows() { + // Given + let core = NOPDatadogCore() + let config = RUM.Configuration.URLSessionTracking() + + // When + Then + XCTAssertThrowsError(try RUM._internal.enableURLSessionTracking(with: config, in: core)) + } + + func testWhenRumNotEnabled_lateEnableUrlSessionTrackingThrows() { + // Given + let core = PassthroughCoreMock() + let config = RUM.Configuration.URLSessionTracking() + + // When + Then + XCTAssertThrowsError(try RUM._internal.enableURLSessionTracking(with: config, in: core)) + } + + func testLateEnableUrlSessionTracking() throws { + // Given + let core = FeatureRegistrationCoreMock() + let debugSDK: Bool = .mockRandom() + var rumConfig = RUM.Configuration(applicationID: .mockAny()) + rumConfig.debugSDK = debugSDK + RUM.enable(with: rumConfig, in: core) + let hosts: Set = ["datadoghq.com", "example.com", "localhost"] + let sampleRate: Float = .mockRandom(min: 0.0, max: 1.0) + let hostsTracing: RUM.Configuration.URLSessionTracking.FirstPartyHostsTracing = .trace(hosts: hosts, sampleRate: sampleRate) + + let config = RUM.Configuration.URLSessionTracking( + firstPartyHostsTracing: hostsTracing + ) + + // When + try RUM._internal.enableURLSessionTracking(with: config, in: core) + + // Then + let feature = try XCTUnwrap(core.get(feature: NetworkInstrumentationFeature.self)) + let urlSessionHandler = try XCTUnwrap(feature.handlers.first as? URLSessionRUMResourcesHandler) + XCTAssertEqual(urlSessionHandler.distributedTracing?.firstPartyHosts.hosts, hosts) + XCTAssertEqual(urlSessionHandler.distributedTracing?.sampler.samplingRate, debugSDK ? 100.0 : sampleRate) + } } diff --git a/DatadogSDK.podspec b/DatadogSDK.podspec index f2beb7278f..d59afe8461 100644 --- a/DatadogSDK.podspec +++ b/DatadogSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogSDK" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Swift SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSDKAlamofireExtension.podspec b/DatadogSDKAlamofireExtension.podspec index f5c1421960..88f932705b 100644 --- a/DatadogSDKAlamofireExtension.podspec +++ b/DatadogSDKAlamofireExtension.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKAlamofireExtension" s.module_name = "DatadogAlamofireExtension" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "An Official Extensions of Datadog Swift SDK for Alamofire." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSDKCrashReporting.podspec b/DatadogSDKCrashReporting.podspec index c4b94a6f20..58dd795ce5 100644 --- a/DatadogSDKCrashReporting.podspec +++ b/DatadogSDKCrashReporting.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKCrashReporting" s.module_name = "DatadogCrashReporting" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Crash Reporting SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSDKObjc.podspec b/DatadogSDKObjc.podspec index aefd9a3bf2..a3ec409743 100644 --- a/DatadogSDKObjc.podspec +++ b/DatadogSDKObjc.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "DatadogSDKObjc" s.module_name = "DatadogObjc" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Objective-C SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay.podspec b/DatadogSessionReplay.podspec index 7a5769dd2e..40ba97e63d 100644 --- a/DatadogSessionReplay.podspec +++ b/DatadogSessionReplay.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogSessionReplay" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Official Datadog Session Replay SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift index efab9c1495..069f360401 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/ImageRendering.swift @@ -6,6 +6,7 @@ import UIKit import Framer +@_spi(Internal) @testable import DatadogSessionReplay /// Renders application window into image. diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift index cdaf54b125..35b28abc05 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift @@ -7,6 +7,7 @@ import XCTest import SRFixtures import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay @testable import SRHost @@ -41,11 +42,11 @@ internal class SnapshotTestCase: XCTestCase { // Set up SR recorder: let processor = Processor( queue: NoQueue(), - writer: Writer(), + writer: RecordWriter(core: PassthroughCoreMock()), srContextPublisher: SRContextPublisher(core: PassthroughCoreMock()), telemetry: TelemetryMock() ) - let recorder = try Recorder(processor: processor, telemetry: TelemetryMock()) + let recorder = try Recorder(processor: processor, telemetry: TelemetryMock(), additionalNodeRecorders: []) // Set up wireframes interception : var wireframes: [SRWireframe]? diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/EnrichedRecordJSON.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/EnrichedRecordJSON.swift similarity index 100% rename from DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/EnrichedRecordJSON.swift rename to DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/EnrichedRecordJSON.swift diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/SegmentJSON.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift similarity index 100% rename from DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/SegmentJSON.swift rename to DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/SegmentJSONBuilder.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSONBuilder.swift similarity index 100% rename from DatadogSessionReplay/Sources/Feature/RequestBuilder/JSON/SegmentJSONBuilder.swift rename to DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSONBuilder.swift diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilder/Multipart/MultipartFormData.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/Multipart/MultipartFormData.swift similarity index 100% rename from DatadogSessionReplay/Sources/Feature/RequestBuilder/Multipart/MultipartFormData.swift rename to DatadogSessionReplay/Sources/Feature/RequestBuilders/Multipart/MultipartFormData.swift diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilders/ResourceRequestBuilder.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/ResourceRequestBuilder.swift new file mode 100644 index 0000000000..c27ec93183 --- /dev/null +++ b/DatadogSessionReplay/Sources/Feature/RequestBuilders/ResourceRequestBuilder.swift @@ -0,0 +1,79 @@ +/* + * 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. + */ + +#if os(iOS) +import Foundation +import DatadogInternal + +internal struct ResourceRequestBuilder: FeatureRequestBuilder { + /// Custom URL for uploading data to. + let customUploadURL: URL? + /// Sends telemetry through sdk core. + let telemetry: Telemetry + /// Builds multipart form for request's body. + let multipartBuilder: MultipartFormDataBuilder + + init( + customUploadURL: URL?, + telemetry: Telemetry, + multipartBuilder: MultipartFormDataBuilder = MultipartFormData(boundary: UUID()) + ) { + self.customUploadURL = customUploadURL + self.telemetry = telemetry + self.multipartBuilder = multipartBuilder + } + + func request(for events: [Event], with context: DatadogContext) throws -> URLRequest { + let decoder = JSONDecoder() + let resources = try events.map { event in + try decoder.decode(EnrichedResource.self, from: event.data) + } + return try createRequest(resources: resources, context: context) + } + + private func createRequest(resources: [EnrichedResource], context: DatadogContext) throws -> URLRequest { + var multipart = multipartBuilder + + let builder = URLRequestBuilder( + url: url(with: context), + queryItems: [], + headers: [ + .contentTypeHeader(contentType: .multipartFormData(boundary: multipart.boundary.uuidString)), + .userAgentHeader(appName: context.applicationName, appVersion: context.version, device: context.device), + .ddAPIKeyHeader(clientToken: context.clientToken), + .ddEVPOriginHeader(source: context.source), + .ddEVPOriginVersionHeader(sdkVersion: context.sdkVersion), + .ddRequestIDHeader(), + ], + telemetry: telemetry + ) + + resources.forEach { + multipart.addFormData( + name: "image", + filename: $0.identifier, + data: $0.data, + mimeType: "image/png" + ) + } + if let context = resources.first?.context { + let data = try JSONEncoder().encode(context) + multipart.addFormData( + name: "event", + filename: "blob", + data: data, + mimeType: "application/json" + ) + } + + return builder.uploadRequest(with: multipart.data, compress: true) + } + + private func url(with context: DatadogContext) -> URL { + customUploadURL ?? context.site.endpoint.appendingPathComponent("api/v2/replay") + } +} +#endif diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilder/RequestBuilder.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/SegmentRequestBuilder.swift similarity index 97% rename from DatadogSessionReplay/Sources/Feature/RequestBuilder/RequestBuilder.swift rename to DatadogSessionReplay/Sources/Feature/RequestBuilders/SegmentRequestBuilder.swift index 26fb7e6071..1cda1ff4fd 100644 --- a/DatadogSessionReplay/Sources/Feature/RequestBuilder/RequestBuilder.swift +++ b/DatadogSessionReplay/Sources/Feature/RequestBuilders/SegmentRequestBuilder.swift @@ -8,7 +8,7 @@ import Foundation import DatadogInternal -internal struct RequestBuilder: FeatureRequestBuilder { +internal struct SegmentRequestBuilder: FeatureRequestBuilder { private static let newlineByte = "\n".data(using: .utf8)! // swiftlint:disable:this force_unwrapping /// Custom URL for uploading data to. @@ -54,7 +54,7 @@ internal struct RequestBuilder: FeatureRequestBuilder { // Session Replay BE accepts compressed segment data followed by newline character (before compression): var segmentData = try JSONSerialization.data(withJSONObject: segment.toJSONObject()) - segmentData.append(RequestBuilder.newlineByte) + segmentData.append(SegmentRequestBuilder.newlineByte) let compressedSegmentData = try SRCompression.compress(data: segmentData) // Compressed segment is sent within multipart form data - with some of segment (metadata) diff --git a/DatadogSessionReplay/Sources/Feature/ResourcesFeature.swift b/DatadogSessionReplay/Sources/Feature/ResourcesFeature.swift new file mode 100644 index 0000000000..b0e94e04c1 --- /dev/null +++ b/DatadogSessionReplay/Sources/Feature/ResourcesFeature.swift @@ -0,0 +1,28 @@ +/* + * 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. + */ + +#if os(iOS) +import Foundation +import DatadogInternal + +internal class ResourcesFeature: DatadogRemoteFeature { + static var name = "session-replay-resources" + + let messageReceiver: FeatureMessageReceiver = NOPFeatureMessageReceiver() + + let requestBuilder: FeatureRequestBuilder + + init( + core: DatadogCoreProtocol, + configuration: SessionReplay.Configuration + ) { + self.requestBuilder = ResourceRequestBuilder( + customUploadURL: configuration.customEndpoint, + telemetry: core.telemetry + ) + } +} +#endif diff --git a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift index ac1f773b9d..e0d05040e9 100644 --- a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift +++ b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift @@ -20,8 +20,6 @@ internal class SessionReplayFeature: DatadogRemoteFeature { let recordingCoordinator: RecordingCoordinator /// Processes each new snapshot on a background thread and transforms it into records. let processor: Processing - /// Writes records to sdk core. - let writer: Writing // MARK: - Initialization @@ -29,11 +27,9 @@ internal class SessionReplayFeature: DatadogRemoteFeature { core: DatadogCoreProtocol, configuration: SessionReplay.Configuration ) throws { - let writer = Writer() - let processor = Processor( queue: BackgroundAsyncQueue(named: "com.datadoghq.session-replay.processor"), - writer: writer, + writer: RecordWriter(core: core), srContextPublisher: SRContextPublisher(core: core), telemetry: core.telemetry ) @@ -43,7 +39,8 @@ internal class SessionReplayFeature: DatadogRemoteFeature { let recorder = try Recorder( processor: processor, - telemetry: core.telemetry + telemetry: core.telemetry, + additionalNodeRecorders: configuration._additionalNodeRecorders ) let recordingCoordinator = RecordingCoordinator( scheduler: scheduler, @@ -57,8 +54,7 @@ internal class SessionReplayFeature: DatadogRemoteFeature { self.messageReceiver = messageReceiver self.recordingCoordinator = recordingCoordinator self.processor = processor - self.writer = writer - self.requestBuilder = RequestBuilder( + self.requestBuilder = SegmentRequestBuilder( customUploadURL: configuration.customEndpoint, telemetry: core.telemetry ) diff --git a/DatadogSessionReplay/Sources/Writer/Models/EnrichedRecord.swift b/DatadogSessionReplay/Sources/Models/EnrichedRecord.swift similarity index 100% rename from DatadogSessionReplay/Sources/Writer/Models/EnrichedRecord.swift rename to DatadogSessionReplay/Sources/Models/EnrichedRecord.swift diff --git a/DatadogSessionReplay/Sources/Models/EnrichedResource.swift b/DatadogSessionReplay/Sources/Models/EnrichedResource.swift new file mode 100644 index 0000000000..78e78fceb0 --- /dev/null +++ b/DatadogSessionReplay/Sources/Models/EnrichedResource.swift @@ -0,0 +1,42 @@ +/* + * 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. + */ + +#if os(iOS) +import Foundation + +/// Extends the resource information with context. +internal struct EnrichedResource: Codable, Resource { + internal struct Context: Codable, Equatable { + internal struct Application: Codable, Equatable { + let id: String + } + let type: String + let application: Application + + init(_ applicationId: String) { + self.type = "resource" + self.application = .init(id: applicationId) + } + } + internal var identifier: String + internal var data: Data + internal var context: Context + + internal init(resource: Resource, context: Context) { + self.init(identifier: resource.identifier, data: resource.data, context: context) + } + + internal init( + identifier: String, + data: Data, + context: Context + ) { + self.identifier = identifier + self.data = data + self.context = context + } +} +#endif diff --git a/DatadogSessionReplay/Sources/Writer/Models/SRDataModels+UIKit.swift b/DatadogSessionReplay/Sources/Models/SRDataModels+UIKit.swift similarity index 96% rename from DatadogSessionReplay/Sources/Writer/Models/SRDataModels+UIKit.swift rename to DatadogSessionReplay/Sources/Models/SRDataModels+UIKit.swift index 65fb3b2674..e8b7e13651 100644 --- a/DatadogSessionReplay/Sources/Writer/Models/SRDataModels+UIKit.swift +++ b/DatadogSessionReplay/Sources/Models/SRDataModels+UIKit.swift @@ -9,7 +9,8 @@ import UIKit extension SRTextPosition.Alignment { /// Custom initializer that allows transforming UIKit's `NSTextAlignment` into `SRTextPosition.Alignment`. - init( + @_spi(Internal) + public init( systemTextAlignment: NSTextAlignment, vertical: SRTextPosition.Alignment.Vertical = .center ) { diff --git a/DatadogSessionReplay/Sources/Writer/Models/SRDataModels.swift b/DatadogSessionReplay/Sources/Models/SRDataModels.swift similarity index 78% rename from DatadogSessionReplay/Sources/Writer/Models/SRDataModels.swift rename to DatadogSessionReplay/Sources/Models/SRDataModels.swift index cd671862d6..2358cdad62 100644 --- a/DatadogSessionReplay/Sources/Writer/Models/SRDataModels.swift +++ b/DatadogSessionReplay/Sources/Models/SRDataModels.swift @@ -12,36 +12,37 @@ import DatadogInternal internal protocol SRDataModel: Codable {} /// Mobile-specific. Schema of a Session Replay data Segment. -internal struct SRSegment: SRDataModel { +@_spi(Internal) +public struct SRSegment: SRDataModel { /// Application properties - internal let application: Application + public let application: Application /// The end UTC timestamp in milliseconds corresponding to the last record in the Segment data. Each timestamp is computed as the UTC interval since 00:00:00.000 01.01.1970. - internal let end: Int64 + public let end: Int64 /// Whether this Segment contains a full snapshot record or not. - internal let hasFullSnapshot: Bool? + public let hasFullSnapshot: Bool? /// The index of this Segment in the segments list that was recorded for this view ID. Starts from 0. - internal let indexInView: Int64? + public let indexInView: Int64? /// The records contained by this Segment. - internal let records: [SRRecord] + public let records: [SRRecord] /// The number of records in this Segment. - internal let recordsCount: Int64 + public let recordsCount: Int64 /// Session properties - internal let session: Session + public let session: Session /// The source of this record - internal let source: Source + public let source: Source /// The start UTC timestamp in milliseconds corresponding to the first record in the Segment data. Each timestamp is computed as the UTC interval since 00:00:00.000 01.01.1970. - internal let start: Int64 + public let start: Int64 /// View properties - internal let view: View + public let view: View enum CodingKeys: String, CodingKey { case application = "application" @@ -57,9 +58,10 @@ internal struct SRSegment: SRDataModel { } /// Application properties - internal struct Application: Codable { + @_spi(Internal) + public struct Application: Codable { /// UUID of the application - internal let id: String + public let id: String enum CodingKeys: String, CodingKey { case id = "id" @@ -67,9 +69,10 @@ internal struct SRSegment: SRDataModel { } /// Session properties - internal struct Session: Codable { + @_spi(Internal) + public struct Session: Codable { /// UUID of the session - internal let id: String + public let id: String enum CodingKeys: String, CodingKey { case id = "id" @@ -77,7 +80,8 @@ internal struct SRSegment: SRDataModel { } /// The source of this record - internal enum Source: String, Codable { + @_spi(Internal) + public enum Source: String, Codable { case android = "android" case ios = "ios" case flutter = "flutter" @@ -85,9 +89,10 @@ internal struct SRSegment: SRDataModel { } /// View properties - internal struct View: Codable { + @_spi(Internal) + public struct View: Codable { /// UUID of the view - internal let id: String + public let id: String enum CodingKeys: String, CodingKey { case id = "id" @@ -96,12 +101,13 @@ internal struct SRSegment: SRDataModel { } /// The border properties of this wireframe. The default value is null (no-border). -internal struct SRShapeBorder: Codable, Hashable { +@_spi(Internal) +public struct SRShapeBorder: Codable, Hashable { /// The border color as a String hexadecimal. Follows the #RRGGBBAA color format with the alpha value as optional. - internal let color: String + public let color: String /// The width of the border in pixels. - internal let width: Int64 + public let width: Int64 enum CodingKeys: String, CodingKey { case color = "color" @@ -110,18 +116,19 @@ internal struct SRShapeBorder: Codable, Hashable { } /// Schema of clipping information for a Wireframe. -internal struct SRContentClip: Codable, Hashable { +@_spi(Internal) +public struct SRContentClip: Codable, Hashable { /// The amount of space in pixels that needs to be clipped (masked) at the bottom of the wireframe. - internal let bottom: Int64? + public let bottom: Int64? /// The amount of space in pixels that needs to be clipped (masked) at the left of the wireframe. - internal let left: Int64? + public let left: Int64? /// The amount of space in pixels that needs to be clipped (masked) at the right of the wireframe. - internal let right: Int64? + public let right: Int64? /// The amount of space in pixels that needs to be clipped (masked) at the top of the wireframe. - internal let top: Int64? + public let top: Int64? enum CodingKeys: String, CodingKey { case bottom = "bottom" @@ -132,15 +139,16 @@ internal struct SRContentClip: Codable, Hashable { } /// The style of this wireframe. -internal struct SRShapeStyle: Codable, Hashable { +@_spi(Internal) +public struct SRShapeStyle: Codable, Hashable { /// The background color for this wireframe as a String hexadecimal. Follows the #RRGGBBAA color format with the alpha value as optional. The default value is #FFFFFF00. - internal let backgroundColor: String? + public let backgroundColor: String? /// The corner(border) radius of this wireframe in pixels. The default value is 0. - internal let cornerRadius: Double? + public let cornerRadius: Double? /// The opacity of this wireframe. Takes values from 0 to 1, default value is 1. - internal let opacity: Double? + public let opacity: Double? enum CodingKeys: String, CodingKey { case backgroundColor = "backgroundColor" @@ -150,33 +158,34 @@ internal struct SRShapeStyle: Codable, Hashable { } /// Schema of all properties of a ShapeWireframe. -internal struct SRShapeWireframe: Codable, Hashable { +@_spi(Internal) +public struct SRShapeWireframe: Codable, Hashable { /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64 + public let height: Int64 /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The type of the wireframe. - internal let type: String = "shape" + public let type: String = "shape" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64 + public let width: Int64 /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64 + public let x: Int64 /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64 + public let y: Int64 enum CodingKeys: String, CodingKey { case border = "border" @@ -192,22 +201,24 @@ internal struct SRShapeWireframe: Codable, Hashable { } /// Schema of all properties of a TextPosition. -internal struct SRTextPosition: Codable, Hashable { - internal let alignment: Alignment? +@_spi(Internal) +public struct SRTextPosition: Codable, Hashable { + public let alignment: Alignment? - internal let padding: Padding? + public let padding: Padding? enum CodingKeys: String, CodingKey { case alignment = "alignment" case padding = "padding" } - internal struct Alignment: Codable, Hashable { + @_spi(Internal) + public struct Alignment: Codable, Hashable { /// The horizontal text alignment. The default value is `left`. - internal let horizontal: Horizontal? + public let horizontal: Horizontal? /// The vertical text alignment. The default value is `top`. - internal let vertical: Vertical? + public let vertical: Vertical? enum CodingKeys: String, CodingKey { case horizontal = "horizontal" @@ -215,32 +226,35 @@ internal struct SRTextPosition: Codable, Hashable { } /// The horizontal text alignment. The default value is `left`. - internal enum Horizontal: String, Codable { + @_spi(Internal) + public enum Horizontal: String, Codable { case left = "left" case right = "right" case center = "center" } /// The vertical text alignment. The default value is `top`. - internal enum Vertical: String, Codable { + @_spi(Internal) + public enum Vertical: String, Codable { case top = "top" case bottom = "bottom" case center = "center" } } - internal struct Padding: Codable, Hashable { + @_spi(Internal) + public struct Padding: Codable, Hashable { /// The bottom padding in pixels. The default value is 0. - internal let bottom: Int64? + public let bottom: Int64? /// The left padding in pixels. The default value is 0. - internal let left: Int64? + public let left: Int64? /// The right padding in pixels. The default value is 0. - internal let right: Int64? + public let right: Int64? /// The top padding in pixels. The default value is 0. - internal let top: Int64? + public let top: Int64? enum CodingKeys: String, CodingKey { case bottom = "bottom" @@ -252,15 +266,16 @@ internal struct SRTextPosition: Codable, Hashable { } /// Schema of all properties of a TextStyle. -internal struct SRTextStyle: Codable, Hashable { +@_spi(Internal) +public struct SRTextStyle: Codable, Hashable { /// The font color as a string hexadecimal. Follows the #RRGGBBAA color format with the alpha value as optional. - internal let color: String + public let color: String /// The preferred font family collection, ordered by preference and formatted as a String list: e.g. Century Gothic, Verdana, sans-serif - internal let family: String + public let family: String /// The font size in pixels. - internal let size: Int64 + public let size: Int64 enum CodingKeys: String, CodingKey { case color = "color" @@ -270,42 +285,43 @@ internal struct SRTextStyle: Codable, Hashable { } /// Schema of all properties of a TextWireframe. -internal struct SRTextWireframe: Codable, Hashable { +@_spi(Internal) +public struct SRTextWireframe: Codable, Hashable { /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64 + public let height: Int64 /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The text value of the wireframe. - internal var text: String + public var text: String /// Schema of all properties of a TextPosition. - internal let textPosition: SRTextPosition? + public let textPosition: SRTextPosition? /// Schema of all properties of a TextStyle. - internal let textStyle: SRTextStyle + public let textStyle: SRTextStyle /// The type of the wireframe. - internal let type: String = "text" + public let type: String = "text" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64 + public let width: Int64 /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64 + public let x: Int64 /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64 + public let y: Int64 enum CodingKeys: String, CodingKey { case border = "border" @@ -324,45 +340,46 @@ internal struct SRTextWireframe: Codable, Hashable { } /// Schema of all properties of a ImageWireframe. -internal struct SRImageWireframe: Codable { +@_spi(Internal) +public struct SRImageWireframe: Codable, Hashable { /// base64 representation of the image. Not required as the ImageWireframe can be initialised without any base64 - internal var base64: String? + public var base64: String? /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64 + public let height: Int64 /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// Flag describing an image wireframe that should render an empty state placeholder - internal var isEmpty: Bool? + public var isEmpty: Bool? /// MIME type of the image file - internal var mimeType: String? + public var mimeType: String? /// Unique identifier of the image resource - internal var resourceId: String? + public var resourceId: String? /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The type of the wireframe. - internal let type: String = "image" + public let type: String = "image" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64 + public let width: Int64 /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64 + public let x: Int64 /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64 + public let y: Int64 enum CodingKeys: String, CodingKey { case base64 = "base64" @@ -382,30 +399,31 @@ internal struct SRImageWireframe: Codable { } /// Schema of all properties of a PlaceholderWireframe. -internal struct SRPlaceholderWireframe: Codable, Hashable { +@_spi(Internal) +public struct SRPlaceholderWireframe: Codable, Hashable { /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64 + public let height: Int64 /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// Label of the placeholder - internal var label: String? + public var label: String? /// The type of the wireframe. - internal let type: String = "placeholder" + public let type: String = "placeholder" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64 + public let width: Int64 /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64 + public let x: Int64 /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64 + public let y: Int64 enum CodingKeys: String, CodingKey { case clip = "clip" @@ -420,7 +438,8 @@ internal struct SRPlaceholderWireframe: Codable, Hashable { } /// Schema of a Wireframe type. -internal enum SRWireframe: Codable { +@_spi(Internal) +public enum SRWireframe: Codable { case shapeWireframe(value: SRShapeWireframe) case textWireframe(value: SRTextWireframe) case imageWireframe(value: SRImageWireframe) @@ -428,7 +447,7 @@ internal enum SRWireframe: Codable { // MARK: - Codable - internal func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { // Encode only the associated value, without encoding enum case var container = encoder.singleValueContainer() @@ -444,7 +463,7 @@ internal enum SRWireframe: Codable { } } - internal init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { // Decode enum case from associated value let container = try decoder.singleValueContainer() @@ -476,14 +495,15 @@ internal enum SRWireframe: Codable { } /// Mobile-specific. Schema of a Record type which contains the full snapshot of a screen. -internal struct SRFullSnapshotRecord: Codable { - internal let data: Data +@_spi(Internal) +public struct SRFullSnapshotRecord: Codable { + public let data: Data /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 10 + public let type: Int64 = 10 enum CodingKeys: String, CodingKey { case data = "data" @@ -491,9 +511,10 @@ internal struct SRFullSnapshotRecord: Codable { case type = "type" } - internal struct Data: Codable { + @_spi(Internal) + public struct Data: Codable { /// The Wireframes contained by this Record. - internal let wireframes: [SRWireframe] + public let wireframes: [SRWireframe] enum CodingKeys: String, CodingKey { case wireframes = "wireframes" @@ -502,15 +523,16 @@ internal struct SRFullSnapshotRecord: Codable { } /// Mobile-specific. Schema of a Record type which contains mutations of a screen. -internal struct SRIncrementalSnapshotRecord: Codable { +@_spi(Internal) +public struct SRIncrementalSnapshotRecord: Codable { /// Mobile-specific. Schema of a Session Replay IncrementalData type. - internal let data: Data + public let data: Data /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 11 + public let type: Int64 = 11 enum CodingKeys: String, CodingKey { case data = "data" @@ -519,7 +541,8 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Mobile-specific. Schema of a Session Replay IncrementalData type. - internal enum Data: Codable { + @_spi(Internal) + public enum Data: Codable { case mutationData(value: MutationData) case touchData(value: TouchData) case viewportResizeData(value: ViewportResizeData) @@ -527,7 +550,7 @@ internal struct SRIncrementalSnapshotRecord: Codable { // MARK: - Codable - internal func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { // Encode only the associated value, without encoding enum case var container = encoder.singleValueContainer() @@ -543,7 +566,7 @@ internal struct SRIncrementalSnapshotRecord: Codable { } } - internal init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { // Decode enum case from associated value let container = try decoder.singleValueContainer() @@ -574,18 +597,19 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Mobile-specific. Schema of a MutationData. - internal struct MutationData: Codable { + @_spi(Internal) + public struct MutationData: Codable { /// Contains the newly added wireframes. - internal let adds: [Adds] + public let adds: [Adds] /// Contains the removed wireframes as an array of ids. - internal let removes: [Removes] + public let removes: [Removes] /// The source of this type of incremental data. - internal let source: Int64 = 0 + public let source: Int64 = 0 /// Contains the updated wireframes mutations. - internal let updates: [Updates] + public let updates: [Updates] enum CodingKeys: String, CodingKey { case adds = "adds" @@ -594,12 +618,13 @@ internal struct SRIncrementalSnapshotRecord: Codable { case updates = "updates" } - internal struct Adds: Codable { + @_spi(Internal) + public struct Adds: Codable { /// The previous wireframe id next or after which this new wireframe is drawn or attached to, respectively. - internal let previousId: Int64? + public let previousId: Int64? /// Schema of a Wireframe type. - internal let wireframe: SRWireframe + public let wireframe: SRWireframe enum CodingKeys: String, CodingKey { case previousId = "previousId" @@ -607,9 +632,10 @@ internal struct SRIncrementalSnapshotRecord: Codable { } } - internal struct Removes: Codable { + @_spi(Internal) + public struct Removes: Codable { /// The id of the wireframe that needs to be removed. - internal let id: Int64 + public let id: Int64 enum CodingKeys: String, CodingKey { case id = "id" @@ -617,7 +643,8 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a WireframeUpdateMutation type. - internal enum Updates: Codable { + @_spi(Internal) + public enum Updates: Codable { case textWireframeUpdate(value: TextWireframeUpdate) case shapeWireframeUpdate(value: ShapeWireframeUpdate) case imageWireframeUpdate(value: ImageWireframeUpdate) @@ -625,7 +652,7 @@ internal struct SRIncrementalSnapshotRecord: Codable { // MARK: - Codable - internal func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { // Encode only the associated value, without encoding enum case var container = encoder.singleValueContainer() @@ -641,7 +668,7 @@ internal struct SRIncrementalSnapshotRecord: Codable { } } - internal init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { // Decode enum case from associated value let container = try decoder.singleValueContainer() @@ -672,42 +699,43 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of all properties of a TextWireframeUpdate. - internal struct TextWireframeUpdate: Codable { + @_spi(Internal) + public struct TextWireframeUpdate: Codable { /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64? + public let height: Int64? /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The text value of the wireframe. - internal var text: String? + public var text: String? /// Schema of all properties of a TextPosition. - internal let textPosition: SRTextPosition? + public let textPosition: SRTextPosition? /// Schema of all properties of a TextStyle. - internal let textStyle: SRTextStyle? + public let textStyle: SRTextStyle? /// The type of the wireframe. - internal let type: String = "text" + public let type: String = "text" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64? + public let width: Int64? /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64? + public let x: Int64? /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64? + public let y: Int64? enum CodingKeys: String, CodingKey { case border = "border" @@ -726,33 +754,34 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a ShapeWireframeUpdate. - internal struct ShapeWireframeUpdate: Codable { + @_spi(Internal) + public struct ShapeWireframeUpdate: Codable { /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64? + public let height: Int64? /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The type of the wireframe. - internal let type: String = "shape" + public let type: String = "shape" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64? + public let width: Int64? /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64? + public let x: Int64? /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64? + public let y: Int64? enum CodingKeys: String, CodingKey { case border = "border" @@ -768,45 +797,46 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of all properties of a ImageWireframeUpdate. - internal struct ImageWireframeUpdate: Codable { + @_spi(Internal) + public struct ImageWireframeUpdate: Codable { /// base64 representation of the image. Not required as the ImageWireframe can be initialised without any base64 - internal var base64: String? + public var base64: String? /// The border properties of this wireframe. The default value is null (no-border). - internal let border: SRShapeBorder? + public let border: SRShapeBorder? /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64? + public let height: Int64? /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// Flag describing an image wireframe that should render an empty state placeholder - internal var isEmpty: Bool? + public var isEmpty: Bool? /// MIME type of the image file - internal var mimeType: String? + public var mimeType: String? /// Unique identifier of the image resource - internal var resourceId: String? + public var resourceId: String? /// The style of this wireframe. - internal let shapeStyle: SRShapeStyle? + public let shapeStyle: SRShapeStyle? /// The type of the wireframe. - internal let type: String = "image" + public let type: String = "image" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64? + public let width: Int64? /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64? + public let x: Int64? /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64? + public let y: Int64? enum CodingKeys: String, CodingKey { case base64 = "base64" @@ -826,30 +856,31 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of all properties of a PlaceholderWireframe. - internal struct PlaceholderWireframeUpdate: Codable { + @_spi(Internal) + public struct PlaceholderWireframeUpdate: Codable { /// Schema of clipping information for a Wireframe. - internal let clip: SRContentClip? + public let clip: SRContentClip? /// The height in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height of all UI elements is divided by 2 to get a normalized height. - internal let height: Int64? + public let height: Int64? /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. - internal let id: Int64 + public let id: Int64 /// Label of the placeholder - internal var label: String? + public var label: String? /// The type of the wireframe. - internal let type: String = "placeholder" + public let type: String = "placeholder" /// The width in pixels of the UI element, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width of all UI elements is divided by 2 to get a normalized width. - internal let width: Int64? + public let width: Int64? /// The position in pixels on X axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let x: Int64? + public let x: Int64? /// The position in pixels on Y axis of the UI element in absolute coordinates. The anchor point is always the top-left corner of the wireframe. - internal let y: Int64? + public let y: Int64? enum CodingKeys: String, CodingKey { case clip = "clip" @@ -866,30 +897,32 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a TouchData. - internal struct TouchData: Codable { + @_spi(Internal) + public struct TouchData: Codable { /// Contains the positions of the finger on the screen during the touchDown/touchUp event lifecycle. - internal let positions: [Positions]? + public let positions: [Positions]? /// The source of this type of incremental data. - internal let source: Int64 = 2 + public let source: Int64 = 2 enum CodingKeys: String, CodingKey { case positions = "positions" case source = "source" } - internal struct Positions: Codable { + @_spi(Internal) + public struct Positions: Codable { /// The touch id of the touch event this position corresponds to. In mobile it is possible to have multiple touch events (fingers touching the screen) happening at the same time. - internal let id: Int64 + public let id: Int64 /// The UTC timestamp in milliseconds corresponding to the moment the position change was recorded. Each timestamp is computed as the UTC interval since 00:00:00.000 01.01.1970. - internal let timestamp: Int64 + public let timestamp: Int64 /// The x coordinate value of the position. - internal let x: Int64 + public let x: Int64 /// The y coordinate value of the position. - internal let y: Int64 + public let y: Int64 enum CodingKeys: String, CodingKey { case id = "id" @@ -901,15 +934,16 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a ViewportResizeData. - internal struct ViewportResizeData: Codable { + @_spi(Internal) + public struct ViewportResizeData: Codable { /// The new height of the screen in pixels, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the height is divided by 2 to get a normalized height. - internal let height: Int64 + public let height: Int64 /// The source of this type of incremental data. - internal let source: Int64 = 4 + public let source: Int64 = 4 /// The new width of the screen in pixels, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the width is divided by 2 to get a normalized width. - internal let width: Int64 + public let width: Int64 enum CodingKeys: String, CodingKey { case height = "height" @@ -919,24 +953,25 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a PointerInteractionData. - internal struct PointerInteractionData: Codable { + @_spi(Internal) + public struct PointerInteractionData: Codable { /// Schema of an PointerEventType - internal let pointerEventType: PointerEventType + public let pointerEventType: PointerEventType /// Id of the pointer of this PointerInteraction. - internal let pointerId: Int64 + public let pointerId: Int64 /// Schema of an PointerType - internal let pointerType: PointerType + public let pointerType: PointerType /// The source of this type of incremental data. - internal let source: Int64 = 9 + public let source: Int64 = 9 /// X-axis coordinate for this PointerInteraction. - internal let x: Double + public let x: Double /// Y-axis coordinate for this PointerInteraction. - internal let y: Double + public let y: Double enum CodingKeys: String, CodingKey { case pointerEventType = "pointerEventType" @@ -948,14 +983,16 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of an PointerEventType - internal enum PointerEventType: String, Codable { + @_spi(Internal) + public enum PointerEventType: String, Codable { case down = "down" case up = "up" case move = "move" } /// Schema of an PointerType - internal enum PointerType: String, Codable { + @_spi(Internal) + public enum PointerType: String, Codable { case mouse = "mouse" case touch = "touch" case pen = "pen" @@ -965,15 +1002,16 @@ internal struct SRIncrementalSnapshotRecord: Codable { } /// Schema of a Record which contains the screen properties. -internal struct SRMetaRecord: Codable { +@_spi(Internal) +public struct SRMetaRecord: Codable { /// The data contained by this record. - internal let data: Data + public let data: Data /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 4 + public let type: Int64 = 4 enum CodingKeys: String, CodingKey { case data = "data" @@ -982,15 +1020,16 @@ internal struct SRMetaRecord: Codable { } /// The data contained by this record. - internal struct Data: Codable { + @_spi(Internal) + public struct Data: Codable { /// The height of the screen in pixels, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the normalized height is the current height divided by 2. - internal let height: Int64 + public let height: Int64 /// Browser-specific. URL of the view described by this record. - internal let href: String? + public let href: String? /// The width of the screen in pixels, normalized based on the device pixels per inch density (DPI). Example: if a device has a DPI = 2, the normalized width is the current width divided by 2. - internal let width: Int64 + public let width: Int64 enum CodingKeys: String, CodingKey { case height = "height" @@ -1001,14 +1040,15 @@ internal struct SRMetaRecord: Codable { } /// Schema of a Record type which contains focus information. -internal struct SRFocusRecord: Codable { - internal let data: Data +@_spi(Internal) +public struct SRFocusRecord: Codable { + public let data: Data /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 6 + public let type: Int64 = 6 enum CodingKeys: String, CodingKey { case data = "data" @@ -1016,9 +1056,10 @@ internal struct SRFocusRecord: Codable { case type = "type" } - internal struct Data: Codable { + @_spi(Internal) + public struct Data: Codable { /// Whether this screen has a focus or not. For now it will always be true for mobile. - internal let hasFocus: Bool + public let hasFocus: Bool enum CodingKeys: String, CodingKey { case hasFocus = "has_focus" @@ -1027,12 +1068,13 @@ internal struct SRFocusRecord: Codable { } /// Schema of a Record which signifies that view lifecycle ended. -internal struct SRViewEndRecord: Codable { +@_spi(Internal) +public struct SRViewEndRecord: Codable { /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 7 + public let type: Int64 = 7 enum CodingKeys: String, CodingKey { case timestamp = "timestamp" @@ -1041,14 +1083,15 @@ internal struct SRViewEndRecord: Codable { } /// Schema of a Record which signifies that the viewport properties have changed. -internal struct SRVisualViewportRecord: Codable { - internal let data: Data +@_spi(Internal) +public struct SRVisualViewportRecord: Codable { + public let data: Data /// Defines the UTC time in milliseconds when this Record was performed. - internal let timestamp: Int64 + public let timestamp: Int64 /// The type of this Record. - internal let type: Int64 = 8 + public let type: Int64 = 8 enum CodingKeys: String, CodingKey { case data = "data" @@ -1056,20 +1099,21 @@ internal struct SRVisualViewportRecord: Codable { case type = "type" } - internal struct Data: Codable { - internal let height: Double + @_spi(Internal) + public struct Data: Codable { + public let height: Double - internal let offsetLeft: Double + public let offsetLeft: Double - internal let offsetTop: Double + public let offsetTop: Double - internal let pageLeft: Double + public let pageLeft: Double - internal let pageTop: Double + public let pageTop: Double - internal let scale: Double + public let scale: Double - internal let width: Double + public let width: Double enum CodingKeys: String, CodingKey { case height = "height" @@ -1084,7 +1128,8 @@ internal struct SRVisualViewportRecord: Codable { } /// Mobile-specific. Schema of a Session Replay Record. -internal enum SRRecord: Codable { +@_spi(Internal) +public enum SRRecord: Codable { case fullSnapshotRecord(value: SRFullSnapshotRecord) case incrementalSnapshotRecord(value: SRIncrementalSnapshotRecord) case metaRecord(value: SRMetaRecord) @@ -1094,7 +1139,7 @@ internal enum SRRecord: Codable { // MARK: - Codable - internal func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { // Encode only the associated value, without encoding enum case var container = encoder.singleValueContainer() @@ -1114,7 +1159,7 @@ internal enum SRRecord: Codable { } } - internal init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { // Decode enum case from associated value let container = try decoder.singleValueContainer() @@ -1152,6 +1197,5 @@ internal enum SRRecord: Codable { throw DecodingError.typeMismatch(SRRecord.self, error) } } - -// Generated from https://github.com/DataDog/rum-events-format/tree/5a79d9a36b6e76493420792055fc50aed780569b #endif +// Generated from https://github.com/DataDog/rum-events-format/tree/5a79d9a36b6e76493420792055fc50aed780569b diff --git a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift index ce34120bf4..aeaf6b5f86 100644 --- a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift +++ b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift @@ -175,8 +175,9 @@ extension SRTextWireframe: MutableWireframe { } } -extension SRImageWireframe: Hashable { - static func == (lhs: SRImageWireframe, rhs: SRImageWireframe) -> Bool { +extension SRImageWireframe { + @_spi(Internal) + public static func == (lhs: SRImageWireframe, rhs: SRImageWireframe) -> Bool { return lhs.id == rhs.id && lhs.resourceId == rhs.resourceId && lhs.border == rhs.border diff --git a/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift b/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift index 13fb92cff4..d7c43cca37 100644 --- a/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift +++ b/DatadogSessionReplay/Sources/Processor/Flattening/NodesFlattener.swift @@ -32,8 +32,10 @@ internal struct NodesFlattener { return dropPreviousNode ? nil : previousNode } - - flattened.append(nextNode) + // Add only node that intersects with the screen bounds + if CGRect(origin: .zero, size: snapshot.viewportSize).intersects(nextNode.wireframesBuilder.wireframeRect) { + flattened.append(nextNode) + } } return flattened diff --git a/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift b/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift index 285802c9ce..2cc334ef13 100644 --- a/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift +++ b/DatadogSessionReplay/Sources/Processor/Privacy/TextObfuscator.swift @@ -7,13 +7,16 @@ #if os(iOS) import Foundation -internal protocol TextObfuscating { +@_spi(Internal) +public protocol SessionReplayTextObfuscating { /// Obfuscates given `text`. /// - Parameter text: the text to be obfuscated /// - Returns: obfuscated text func mask(text: String) -> String } +internal typealias TextObfuscating = SessionReplayTextObfuscating + /// Text obfuscator which replaces all readable characters with space-preserving `"x"` characters. internal struct SpacePreservingMaskObfuscator: TextObfuscating { /// The character to mask text with. diff --git a/DatadogSessionReplay/Sources/Processor/Processor.swift b/DatadogSessionReplay/Sources/Processor/Processor.swift index e32e8142a0..2a1b03803e 100644 --- a/DatadogSessionReplay/Sources/Processor/Processor.swift +++ b/DatadogSessionReplay/Sources/Processor/Processor.swift @@ -41,7 +41,7 @@ internal class Processor: Processing { /// The background queue for executing all logic. private let queue: Queue /// Writes records to `DatadogCore`. - private let writer: Writing + private let writer: RecordWriting /// Sends telemetry through sdk core. private let telemetry: Telemetry @@ -62,7 +62,7 @@ internal class Processor: Processing { init( queue: Queue, - writer: Writing, + writer: RecordWriting, srContextPublisher: SRContextPublisher, telemetry: Telemetry ) { diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift b/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift index c94869d0b2..8777d561e1 100644 --- a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift +++ b/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift @@ -62,7 +62,11 @@ internal class RecordsBuilder { /// /// It may return `nil` if there is no diff between `wireframes` and `lastWireframes`. /// In case of unexpected failure, it will fallback to creating FSR instead. + /// If the root wireframe has changed, we trigger a full snapshot so it is added first in the replay. func createIncrementalSnapshotRecord(from snapshot: ViewTreeSnapshot, with wireframes: [SRWireframe], lastWireframes: [SRWireframe]) -> SRRecord? { + if wireframes.first?.id != lastWireframes.first?.id { + return createFullSnapshotRecord(from: snapshot, wireframes: wireframes) + } do { return try createIncrementalSnapshotRecord(from: snapshot, newWireframes: wireframes, lastWireframes: lastWireframes) } catch { diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift b/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift index f78af2b8e6..2731e32117 100644 --- a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift +++ b/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift @@ -9,7 +9,8 @@ import Foundation import CoreGraphics import UIKit -internal typealias WireframeID = NodeID +@_spi(Internal) +public typealias WireframeID = NodeID /// Builds the actual wireframes from VTS snapshots (produced by `Recorder`) to be later transported in SR /// records (see `RecordsBuilder`) within SR segments (see `SegmentBuilder`). @@ -17,7 +18,8 @@ internal typealias WireframeID = NodeID /// It is used by the player to reconstruct individual elements of the recorded app UI. /// /// Note: `WireframesBuilder` is used by `Processor` on a single background thread. -internal class WireframesBuilder { +@_spi(Internal) +public class SessionReplayWireframesBuilder { /// A set of fallback values to use if the actual value cannot be read or converted. /// /// The idea is to always provide value, which would make certain element visible in the player. @@ -34,7 +36,15 @@ internal class WireframesBuilder { static let fontSize: CGFloat = 10 } - func createShapeWireframe( + public struct FontOverride { + let size: CGFloat? + + public init(size: CGFloat?) { + self.size = size + } + } + + public func createShapeWireframe( id: WireframeID, frame: CGRect, clip: SRContentClip? = nil, @@ -58,7 +68,7 @@ internal class WireframesBuilder { return .shapeWireframe(value: wireframe) } - func createImageWireframe( + public func createImageWireframe( imageResource: ImageResource, id: WireframeID, frame: CGRect, @@ -87,7 +97,7 @@ internal class WireframesBuilder { return .imageWireframe(value: wireframe) } - func createTextWireframe( + public func createTextWireframe( id: WireframeID, frame: CGRect, text: String, @@ -96,6 +106,7 @@ internal class WireframesBuilder { clip: SRContentClip? = nil, textColor: CGColor? = nil, font: UIFont? = nil, + fontOverride: FontOverride? = nil, fontScalingEnabled: Bool = false, borderColor: CGColor? = nil, borderWidth: CGFloat? = nil, @@ -114,7 +125,7 @@ internal class WireframesBuilder { ) ) - var fontSize = Int64(withNoOverflow: font?.pointSize ?? Fallback.fontSize) + var fontSize = Int64(withNoOverflow: fontOverride?.size ?? font?.pointSize ?? Fallback.fontSize) if text.count > 0, fontScalingEnabled { // Calculates the approximate font size for available text area √(frameArea / numberOfCharacters) let area = textFrame.width * textFrame.height @@ -148,7 +159,7 @@ internal class WireframesBuilder { return .textWireframe(value: wireframe) } - func createPlaceholderWireframe( + public func createPlaceholderWireframe( id: Int64, frame: CGRect, label: String, @@ -192,6 +203,9 @@ internal class WireframesBuilder { } } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias WireframesBuilder = SessionReplayWireframesBuilder + // MARK: - Convenience internal extension WireframesBuilder { @@ -208,4 +222,22 @@ internal extension WireframesBuilder { ) } } + +extension SRContentClip { + /// This method is a convenience for exposing the internal default init. + @_spi(Internal) + public static func create( + bottom: Int64?, + left: Int64?, + right: Int64?, + top: Int64? + ) -> SRContentClip { + return SRContentClip( + bottom: bottom, + left: left, + right: right, + top: top + ) + } +} #endif diff --git a/DatadogSessionReplay/Sources/Recorder/PrivacyLevel.swift b/DatadogSessionReplay/Sources/Recorder/PrivacyLevel.swift index 4fa020b592..0c44ca3980 100644 --- a/DatadogSessionReplay/Sources/Recorder/PrivacyLevel.swift +++ b/DatadogSessionReplay/Sources/Recorder/PrivacyLevel.swift @@ -5,16 +5,20 @@ */ #if os(iOS) -internal typealias PrivacyLevel = SessionReplay.Configuration.PrivacyLevel +@_spi(Internal) +public typealias SessionReplayPrivacyLevel = SessionReplay.Configuration.PrivacyLevel + +internal typealias PrivacyLevel = SessionReplayPrivacyLevel /// Text obfuscation strategies for different text types. -internal extension SessionReplay.Configuration.PrivacyLevel { +@_spi(Internal) +public extension SessionReplay.Configuration.PrivacyLevel { /// Returns "Sensitive Text" obfuscator for given `privacyLevel`. /// /// In Session Replay, "Sensitive Text" is: /// - passwords, e-mails and phone numbers marked in a platform-specific way /// - AND other forms of sensitivity in text available to each platform - var sensitiveTextObfuscator: TextObfuscating { + var sensitiveTextObfuscator: SessionReplayTextObfuscating { return FixLengthMaskObfuscator() } @@ -23,7 +27,7 @@ internal extension SessionReplay.Configuration.PrivacyLevel { /// In Session Replay, "Input & Option Text" is: /// - a text entered by the user with a keyboard or other text-input device /// - OR a custom (non-generic) value in selection elements - var inputAndOptionTextObfuscator: TextObfuscating { + var inputAndOptionTextObfuscator: SessionReplayTextObfuscating { switch self { case .allow: return NOPTextObfuscator() case .mask: return FixLengthMaskObfuscator() @@ -34,7 +38,7 @@ internal extension SessionReplay.Configuration.PrivacyLevel { /// Returns "Static Text" obfuscator for given `privacyLevel`. /// /// In Session Replay, "Static Text" is a text not directly entered by the user. - var staticTextObfuscator: TextObfuscating { + var staticTextObfuscator: SessionReplayTextObfuscating { switch self { case .allow: return NOPTextObfuscator() case .mask: return SpacePreservingMaskObfuscator() @@ -45,7 +49,7 @@ internal extension SessionReplay.Configuration.PrivacyLevel { /// Returns "Hint Text" obfuscator for given `privacyLevel`. /// /// In Session Replay, "Hint Text" is a static text in editable text elements or option selectors, displayed when there isn't any value set. - var hintTextObfuscator: TextObfuscating { + var hintTextObfuscator: SessionReplayTextObfuscating { switch self { case .allow: return NOPTextObfuscator() case .mask: return FixLengthMaskObfuscator() diff --git a/DatadogSessionReplay/Sources/Recorder/Recorder.swift b/DatadogSessionReplay/Sources/Recorder/Recorder.swift index ddcb50378c..7df68f2386 100644 --- a/DatadogSessionReplay/Sources/Recorder/Recorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/Recorder.swift @@ -18,11 +18,12 @@ internal protocol Recording { /// It instruments running application by observing current window(s) and /// captures intermediate representation of the view hierarchy. This representation /// is later passed to `Processor` and turned into wireframes uploaded to the BE. -internal class Recorder: Recording { +@_spi(Internal) +public class Recorder: Recording { /// The context of recording next snapshot. - struct Context: Equatable { + public struct Context: Equatable { /// The content recording policy from the moment of requesting snapshot. - let privacy: PrivacyLevel + public let privacy: SessionReplayPrivacyLevel /// Current RUM application ID - standard UUID string, lowecased. let applicationID: String /// Current RUM session ID - standard UUID string, lowecased. @@ -64,12 +65,13 @@ internal class Recorder: Recording { convenience init( processor: Processing, - telemetry: Telemetry + telemetry: Telemetry, + additionalNodeRecorders: [NodeRecorder] ) throws { let windowObserver = KeyWindowObserver() let viewTreeSnapshotProducer = WindowViewTreeSnapshotProducer( windowObserver: windowObserver, - snapshotBuilder: ViewTreeSnapshotBuilder() + snapshotBuilder: ViewTreeSnapshotBuilder(additionalNodeRecorders: additionalNodeRecorders) ) let touchSnapshotProducer = WindowTouchSnapshotProducer( windowObserver: windowObserver diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/UIApplicationSwizzler.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/UIApplicationSwizzler.swift index c3568155cc..1cfe2c6744 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/UIApplicationSwizzler.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/UIApplicationSwizzler.swift @@ -10,10 +10,6 @@ import DatadogInternal // MARK: - Copy & Paste from Datadog SDK -// TODO: RUMM-2756 Share following code with V2's core module when its ready -// Following code was copy & pasted from Datadog SDK (without other modifications than declaring -// it a `final` class for mock convenience). After V2 it should be rather moved to common core module. - internal protocol UIEventHandler: AnyObject { func notify_sendEvent(application: UIApplication, event: UIEvent) } @@ -41,11 +37,11 @@ internal final class UIApplicationSwizzler { @convention(block) (UIApplication, UIEvent) -> Bool > { private static let selector = #selector(UIApplication.sendEvent(_:)) - private let method: FoundMethod + private let method: Method private let handler: UIEventHandler init(handler: UIEventHandler) throws { - self.method = try Self.findMethod(with: Self.selector, in: UIApplication.self) + self.method = try dd_class_getInstanceMethod(UIApplication.self, Self.selector) self.handler = handler } @@ -60,121 +56,4 @@ internal final class UIApplicationSwizzler { } } } - -internal class MethodSwizzler { - struct FoundMethod: Hashable { - let method: Method - let klass: AnyClass - - fileprivate init(method: Method, klass: AnyClass) { - self.method = method - self.klass = klass - } - - static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool { - let methodParity = (lhs.method == rhs.method) - let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass)) - return methodParity && classParity - } - - func hash(into hasher: inout Hasher) { - let methodName = NSStringFromSelector(method_getName(method)) - let klassName = NSStringFromClass(klass) - let identifier = "\(methodName)|||\(klassName)" - hasher.combine(identifier) - } - } - - private var implementationCache: [FoundMethod: IMP] = [:] - var swizzledMethods: [FoundMethod] { - return Array(implementationCache.keys) - } - - static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod { - /// NOTE: RUMM-452 as we never add/remove methods/classes at runtime, - /// search operation doesn't have to wrapped in sync {...} although it's visible in the interface - var headKlass: AnyClass? = klass - while let someKlass = headKlass { - if let foundMethod = findMethod(with: selector, in: someKlass) { - return FoundMethod(method: foundMethod, klass: someKlass) - } - headKlass = class_getSuperclass(headKlass) - } - throw InternalError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))") - } - - func originalImplementation(of found: FoundMethod) -> TypedIMP { - return sync { - let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method) - return unsafeBitCast(originalImp, to: TypedIMP.self) - } - } - - func swizzle( - _ foundMethod: FoundMethod, - impProvider: (TypedIMP) -> TypedBlockIMP - ) { - sync { - let currentIMP = method_getImplementation(foundMethod.method) - let current_typedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self) - let newImpBlock: TypedBlockIMP = impProvider(current_typedIMP) - let newImp: IMP = imp_implementationWithBlock(newImpBlock) - - set(newIMP: newImp, for: foundMethod) - - #if DD_SDK_COMPILED_FOR_TESTING - Swizzling.activeSwizzlingNames.append(foundMethod.swizzlingName) - #endif - } - } - - /// Removes swizzling and resets the method to its original implementation. - internal func unswizzle() { - for foundMethod in swizzledMethods { - let originalTypedIMP = originalImplementation(of: foundMethod) - let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self) - method_setImplementation(foundMethod.method, originalIMP) - - Swizzling.activeSwizzlingNames.removeAll { $0 == foundMethod.swizzlingName } - } - } - - // MARK: - Private methods - - @discardableResult - private func sync(block: () -> T) -> T { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - return block() - } - - private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? { - var methodsCount: UInt32 = 0 - let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 } - guard let methods: UnsafeMutablePointer = class_copyMethodList(klass, methodsCountPtr) else { - return nil - } - defer { - free(methods) - } - for index in 0.. NodeID { + public func nodeID(view: UIView, nodeRecorder: SessionReplayNodeRecorder) -> NodeID { if let currentID = view.nodeID?[nodeRecorder.identifier] { return currentID } else { diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/NodeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/NodeRecorder.swift index f6e4930800..cf9a3c00dd 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/NodeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/NodeRecorder.swift @@ -11,31 +11,39 @@ import UIKit /// recognise specialised subclasses of `UIView` and record their semantics accordingly. /// /// **Note:** The `NodeRecorder` is used on the main thread by `Recorder`. -internal protocol NodeRecorder { +@_spi(Internal) +public protocol SessionReplayNodeRecorder { /// Finds the semantic of given`view`. /// - Parameters: /// - view: the `UIView` to determine semantics for /// - attributes: attributes of this view inferred from its base `UIView` interface /// - context: the context of recording current view-tree /// - Returns: the value of `NodeSemantics` or `nil` if the view is a member of view subclass other than the one this recorder is specialised for. - func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? + func semantics(of view: UIView, with attributes: SessionReplayViewAttributes, in context: SessionReplayViewTreeRecordingContext) -> SessionReplayNodeSemantics? /// Unique identifier of the node recorder. var identifier: UUID { get } } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias NodeRecorder = SessionReplayNodeRecorder + /// A type producing SR wireframes. /// /// Each type of UI element (e.g.: label, text field, toggle, button) should provide their own implementaion of `NodeWireframesBuilder`. /// /// **Note:** The `NodeWireframesBuilder` is used on background thread by `Processor`. -internal protocol NodeWireframesBuilder { +@_spi(Internal) +public protocol SessionReplayNodeWireframesBuilder { /// The frame of produced wireframe in screen coordinates. var wireframeRect: CGRect { get } /// Creates wireframes that are later uploaded to SR backend. /// - Parameter builder: the generic builder for constructing SR data models. /// - Returns: one or more wireframes that describe a node in SR. - func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] + func buildWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] } + +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias NodeWireframesBuilder = SessionReplayNodeWireframesBuilder #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift index 9365c1af88..27e0ab1b60 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift @@ -12,19 +12,23 @@ import SwiftUI /// The context of recording subtree hierarchy. /// /// Some fields are mutable, so `NodeRecorders` can specialise it for their subtree traversal. -internal struct ViewTreeRecordingContext { +@_spi(Internal) +public struct SessionReplayViewTreeRecordingContext { /// The context of the Recorder. - let recorder: Recorder.Context + public let recorder: Recorder.Context /// The coordinate space to convert node positions to. let coordinateSpace: UICoordinateSpace /// Generates stable IDs for traversed views. - let ids: NodeIDGenerator + public let ids: NodeIDGenerator /// Provides base64 image data with a built in caching mechanism. let imageDataProvider: ImageDataProviding /// Variable view controller related context var viewControllerContext: ViewControllerContext = .init() } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias ViewTreeRecordingContext = SessionReplayViewTreeRecordingContext + internal extension ViewTreeRecordingContext { /// The `ViewControllerContext` struct is used for storing context-related information about the parent view controller and its type. struct ViewControllerContext { diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 1caaf85482..8472fee09f 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -27,7 +27,7 @@ internal struct ViewTreeSnapshot { let nodes: [Node] } -/// An individual node in `ViewTreeSnapshot`. A `Node` describes a single view - similar: an array of nodes describes +/// An individual node in `ViewTreeSnapshot`. A `SessionReplayNode` describes a single view - similar: an array of nodes describes /// view and its subtree (in depth-first order). /// /// Typically, to describe certain view-tree we need significantly less nodes than number of views, because some views @@ -35,35 +35,57 @@ internal struct ViewTreeSnapshot { /// /// **Note:** The purpose of this structure is to be lightweight and create minimal overhead when the view-tree /// is captured on the main thread (the `Recorder` constantly creates `Nodes` for views residing in the hierarchy). -internal struct Node { +@_spi(Internal) +public struct SessionReplayNode { /// Attributes of the `UIView` that this node was created for. - let viewAttributes: ViewAttributes + public let viewAttributes: SessionReplayViewAttributes /// A type defining how to build SR wireframes for the UI element described by this node. - let wireframesBuilder: NodeWireframesBuilder + public let wireframesBuilder: SessionReplayNodeWireframesBuilder + + public init(viewAttributes: SessionReplayViewAttributes, wireframesBuilder: SessionReplayNodeWireframesBuilder) { + self.viewAttributes = viewAttributes + self.wireframesBuilder = wireframesBuilder + } +} + +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias Node = SessionReplayNode + +// An individual resource in `ViewTreeSnapshot`. It is used to describe binary representation of heavy resources such as images. +@_spi(Internal) +public protocol SessionReplayResource { + /// The unique identifier of the resource. + var identifier: String { get } + /// The data of the resource. + var data: Data { get } } +/// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias Resource = SessionReplayResource + /// Attributes of the `UIView` that the node was created for. /// /// It is used by the `Recorder` to capture view attributes on the main thread. /// It enforces immutability for later (thread safe) access from background queue in `Processor`. -internal struct ViewAttributes: Equatable { +@_spi(Internal) +public struct SessionReplayViewAttributes: Equatable { /// The view's `frame`, in VTS's root view's coordinate space (usually, the screen coordinate space). - let frame: CGRect + public let frame: CGRect /// Original view's `.backgorundColor`. - let backgroundColor: CGColor? + public let backgroundColor: CGColor? /// Original view's `layer.borderColor`. - let layerBorderColor: CGColor? + public let layerBorderColor: CGColor? /// Original view's `layer.borderWidth`. - let layerBorderWidth: CGFloat + public let layerBorderWidth: CGFloat /// Original view's `layer.cornerRadius`. - let layerCornerRadius: CGFloat + public let layerCornerRadius: CGFloat /// Original view's `.alpha` (between `0.0` and `1.0`). - let alpha: CGFloat + public let alpha: CGFloat /// Original view's `.isHidden`. let isHidden: Bool @@ -99,6 +121,9 @@ internal struct ViewAttributes: Equatable { var isTranslucent: Bool { !isVisible || alpha < 1 || backgroundColor?.alpha ?? 0 < 1 } } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias ViewAttributes = SessionReplayViewAttributes + extension ViewAttributes { init(frameInRootView: CGRect, view: UIView) { self.frame = frameInRootView @@ -132,7 +157,8 @@ extension ViewAttributes { /// be safely ignored in `Recorder` or `Processor` (e.g. a `UILabel` with no text, no border and fully transparent color). /// - `UnknownElement` - the element is of unknown kind, which could indicate an error during view tree traversal (e.g. working on /// assumption that is not met). -internal protocol NodeSemantics { +@_spi(Internal) +public protocol SessionReplayNodeSemantics { /// The severity of this semantic. /// /// While querying certain `view` with an array of supported `NodeRecorders` each recorder can spot different semantics of @@ -140,11 +166,14 @@ internal protocol NodeSemantics { static var importance: Int { get } /// Defines the strategy which `Recorder` should apply to subtree of this node. - var subtreeStrategy: NodeSubtreeStrategy { get } + var subtreeStrategy: SessionReplayNodeSubtreeStrategy { get } /// Nodes that share this semantics. - var nodes: [Node] { get } + var nodes: [SessionReplayNode] { get } } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias NodeSemantics = SessionReplayNodeSemantics + extension NodeSemantics { /// The severity of this semantic. /// @@ -153,8 +182,12 @@ extension NodeSemantics { var importance: Int { Self.importance } } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias NodeSubtreeStrategy = SessionReplayNodeSubtreeStrategy + /// Strategies for handling node's subtree by `Recorder`. -internal enum NodeSubtreeStrategy { +@_spi(Internal) +public enum SessionReplayNodeSubtreeStrategy { /// Continue traversing subtree of this node to record nested nodes automatically. /// /// This strategy is particularly useful for semantics that do not make assumption on node's content (e.g. this strategy can be @@ -184,10 +217,11 @@ internal struct UnknownElement: NodeSemantics { /// has no visual appearance that can be presented in SR (e.g. a `UILabel` with no text, no border and fully transparent color). /// Unlike `IgnoredElement`, this semantics can be overwritten with another one with higher importance. This means that even /// if the root view of certain element has no appearance, other node recorders will continue checking it for strictkier semantics. -internal struct InvisibleElement: NodeSemantics { - static let importance: Int = 0 - let subtreeStrategy: NodeSubtreeStrategy - let nodes: [Node] = [] +@_spi(Internal) +public struct SessionReplayInvisibleElement: SessionReplayNodeSemantics { + public static let importance: Int = 0 + public let subtreeStrategy: SessionReplayNodeSubtreeStrategy + public let nodes: [SessionReplayNode] = [] /// Use `InvisibleElement.constant` instead. private init () { @@ -199,9 +233,12 @@ internal struct InvisibleElement: NodeSemantics { } /// A constant value of `InvisibleElement` semantics. - static let constant = InvisibleElement() + public static let constant = SessionReplayInvisibleElement() } +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias InvisibleElement = SessionReplayInvisibleElement + /// A semantics of an UI element that should be ignored when traversing view-tree. Unlike `InvisibleElement` this semantics cannot /// be overwritten by any other. This means that next node recorders won't be asked for further check of a strictkier semantics. internal struct IgnoredElement: NodeSemantics { @@ -223,9 +260,18 @@ internal struct AmbiguousElement: NodeSemantics { /// A semantics of an UI element that is one of `UIView` subclasses. This semantics mean that we know its full identity along with set of /// subclass-specific attributes that will be used to render it in SR (e.g. all base `UIView` attributes plus the text in `UILabel` or the /// "on" / "off" state of `UISwitch` control). -internal struct SpecificElement: NodeSemantics { - static let importance: Int = .max - let subtreeStrategy: NodeSubtreeStrategy - let nodes: [Node] +@_spi(Internal) +public struct SessionReplaySpecificElement: SessionReplayNodeSemantics { + public static let importance: Int = .max + public let subtreeStrategy: SessionReplayNodeSubtreeStrategy + public let nodes: [SessionReplayNode] + + public init(subtreeStrategy: SessionReplayNodeSubtreeStrategy, nodes: [SessionReplayNode]) { + self.subtreeStrategy = subtreeStrategy + self.nodes = nodes + } } + +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias SpecificElement = SessionReplaySpecificElement #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift index 0595105123..66590ae468 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift @@ -44,9 +44,9 @@ internal struct ViewTreeSnapshotBuilder { } extension ViewTreeSnapshotBuilder { - init() { + init(additionalNodeRecorders: [NodeRecorder]) { self.init( - viewTreeRecorder: ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()), + viewTreeRecorder: ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders() + additionalNodeRecorders), idsGenerator: NodeIDGenerator(), imageDataProvider: ImageDataProvider() ) diff --git a/DatadogSessionReplay/Sources/SessionReplay.swift b/DatadogSessionReplay/Sources/SessionReplay.swift index 5735516fc5..80272192e4 100644 --- a/DatadogSessionReplay/Sources/SessionReplay.swift +++ b/DatadogSessionReplay/Sources/SessionReplay.swift @@ -9,7 +9,7 @@ import Foundation import DatadogInternal /// An entry point to Datadog Session Replay feature. -public struct SessionReplay { +public enum SessionReplay { /// Enables Datadog Session Replay feature. /// /// Recording will start automatically after enabling Session Replay. @@ -41,7 +41,8 @@ public struct SessionReplay { let sessionReplay = try SessionReplayFeature(core: core, configuration: configuration) try core.register(feature: sessionReplay) - sessionReplay.writer.startWriting(to: core) + let resources = ResourcesFeature(core: core, configuration: configuration) + try core.register(feature: resources) } } #endif diff --git a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift index eebcf58c7d..e509329c7a 100644 --- a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift +++ b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift @@ -47,6 +47,8 @@ extension SessionReplay { internal var debugSDK: Bool = ProcessInfo.processInfo.arguments.contains(LaunchArguments.Debug) + internal var _additionalNodeRecorders: [NodeRecorder] = [] + /// Creates Session Replay configuration /// - Parameters: /// - replaySampleRate: The sampling rate for Session Replay. It is applied in addition to the RUM session sample rate. @@ -61,6 +63,11 @@ extension SessionReplay { self.defaultPrivacyLevel = defaultPrivacyLevel self.customEndpoint = customEndpoint } + + @_spi(Internal) +public mutating func setAdditionalNodeRecorders(_ additionalNodeRecorders: [SessionReplayNodeRecorder]) { + self._additionalNodeRecorders = additionalNodeRecorders + } } } #endif diff --git a/DatadogSessionReplay/Sources/Writer/Writer.swift b/DatadogSessionReplay/Sources/Writers/RecordWriter.swift similarity index 87% rename from DatadogSessionReplay/Sources/Writer/Writer.swift rename to DatadogSessionReplay/Sources/Writers/RecordWriter.swift index b8c074e581..e4d7647979 100644 --- a/DatadogSessionReplay/Sources/Writer/Writer.swift +++ b/DatadogSessionReplay/Sources/Writers/RecordWriter.swift @@ -9,15 +9,12 @@ import Foundation import DatadogInternal /// A type writing Session Replay records to `DatadogCore`. -internal protocol Writing { - /// Connects writer to SDK core. - func startWriting(to core: DatadogCoreProtocol) - +internal protocol RecordWriting { /// Writes next records to SDK core. func write(nextRecord: EnrichedRecord) } -internal class Writer: Writing { +internal class RecordWriter: RecordWriting { /// An instance of SDK core the SR feature is registered to. private weak var core: DatadogCoreProtocol? @@ -27,12 +24,16 @@ internal class Writer: Writing { /// This is to fulfill the SR payload requirement that each view needs to be send in separate segment. private var lastViewID: String? - // MARK: - Writing - - func startWriting(to core: DatadogCoreProtocol) { + init( + core: DatadogCoreProtocol, + lastViewID: String? = nil + ) { self.core = core + self.lastViewID = lastViewID } + // MARK: - Writing + func write(nextRecord: EnrichedRecord) { let forceNewBatch = lastViewID != nextRecord.viewID lastViewID = nextRecord.viewID diff --git a/DatadogSessionReplay/Sources/Writers/ResourcesWriter.swift b/DatadogSessionReplay/Sources/Writers/ResourcesWriter.swift new file mode 100644 index 0000000000..cabfb4bbd8 --- /dev/null +++ b/DatadogSessionReplay/Sources/Writers/ResourcesWriter.swift @@ -0,0 +1,38 @@ +/* + * 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. + */ + +#if os(iOS) +import Foundation +import DatadogInternal + +/// A type writing Session Replay records to `DatadogCore`. +internal protocol ResourceWriting { + /// Writes next records to SDK core. + func write(resources: [EnrichedResource]) +} + +internal class ResourcesWriter: ResourceWriting { + /// An instance of SDK core the SR feature is registered to. + private weak var core: DatadogCoreProtocol? + + init( + core: DatadogCoreProtocol + ) { + self.core = core + } + + // MARK: - Writing + + func write(resources: [EnrichedResource]) { + guard let scope = core?.scope(for: ResourcesFeature.name) else { + return + } + scope.eventWriteContext(bypassConsent: false, forceNewBatch: false) { _, writer in + writer.write(value: resources) + } + } +} +#endif diff --git a/DatadogSessionReplay/Sources/Writer/SRCompression.swift b/DatadogSessionReplay/Sources/Writers/SRCompression.swift similarity index 100% rename from DatadogSessionReplay/Sources/Writer/SRCompression.swift rename to DatadogSessionReplay/Sources/Writers/SRCompression.swift diff --git a/DatadogSessionReplay/Tests/Feature/RequestBuilder/JSON/SegmentJSONBuilderTests.swift b/DatadogSessionReplay/Tests/Feature/RequestBuilder/JSON/SegmentJSONBuilderTests.swift index d2db5b66da..0e983cdffe 100644 --- a/DatadogSessionReplay/Tests/Feature/RequestBuilder/JSON/SegmentJSONBuilderTests.swift +++ b/DatadogSessionReplay/Tests/Feature/RequestBuilder/JSON/SegmentJSONBuilderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Feature/RequestBuilder/ResourceRequestBuilderTests.swift b/DatadogSessionReplay/Tests/Feature/RequestBuilder/ResourceRequestBuilderTests.swift new file mode 100644 index 0000000000..73c4c731bb --- /dev/null +++ b/DatadogSessionReplay/Tests/Feature/RequestBuilder/ResourceRequestBuilderTests.swift @@ -0,0 +1,158 @@ +/* + * 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 DatadogSessionReplay +@testable import TestUtilities + +class ResourceRequestBuilderTests: XCTestCase { + private let resources = [ + EnrichedResource.mockRandom(), + EnrichedResource.mockRandom(), + EnrichedResource.mockRandom() + ] + private var mockEvents: [Event] { + return resources.map { .mockWith(data: try! JSONEncoder().encode($0)) } + } + + func testItCreatesPOSTRequest() throws { + // Given + let builder = ResourceRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + + // When + let request = try builder.request(for: mockEvents, with: .mockRandom()) + + // Then + XCTAssertEqual(request.httpMethod, "POST") + } + + func testItSetsIntakeURL() { + // Given + let builder = ResourceRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + + // When + func url(for site: DatadogSite) throws -> String { + let request = try builder.request(for: mockEvents, with: .mockWith(site: site)) + return request.url!.absoluteStringWithoutQuery! + } + + // Then + XCTAssertEqual(try url(for: .us1), "https://browser-intake-datadoghq.com/api/v2/replay") + XCTAssertEqual(try url(for: .us3), "https://browser-intake-us3-datadoghq.com/api/v2/replay") + XCTAssertEqual(try url(for: .us5), "https://browser-intake-us5-datadoghq.com/api/v2/replay") + XCTAssertEqual(try url(for: .eu1), "https://browser-intake-datadoghq.eu/api/v2/replay") + XCTAssertEqual(try url(for: .ap1), "https://browser-intake-ap1-datadoghq.com/api/v2/replay") + XCTAssertEqual(try url(for: .us1_fed), "https://browser-intake-ddog-gov.com/api/v2/replay") + } + + func testItSetsCustomIntakeURL() { + // Given + let randomURL: URL = .mockRandom() + let builder = ResourceRequestBuilder(customUploadURL: randomURL, telemetry: TelemetryMock()) + + // When + func url(for site: DatadogSite) throws -> String { + let request = try builder.request(for: mockEvents, with: .mockWith(site: site)) + return request.url!.absoluteStringWithoutQuery! + } + + // Then + let expectedURL = randomURL.absoluteStringWithoutQuery + XCTAssertEqual(try url(for: .us1), expectedURL) + XCTAssertEqual(try url(for: .us3), expectedURL) + XCTAssertEqual(try url(for: .us5), expectedURL) + XCTAssertEqual(try url(for: .eu1), expectedURL) + XCTAssertEqual(try url(for: .ap1), expectedURL) + XCTAssertEqual(try url(for: .us1_fed), expectedURL) + } + + func testItSetsNoQueryParameters() throws { + // Given + let builder = ResourceRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + + // When + let request = try builder.request(for: mockEvents, with: .mockRandom()) + + // Then + XCTAssertEqual(request.url!.query, nil) + } + + func testItSetsHTTPHeaders() throws { + let randomApplicationName: String = .mockRandom(among: .alphanumerics) + let randomVersion: String = .mockRandom(among: .decimalDigits) + let randomSource: String = .mockRandom(among: .alphanumerics) + let randomSDKVersion: String = .mockRandom(among: .alphanumerics) + let randomClientToken: String = .mockRandom() + let randomDeviceName: String = .mockRandom() + let randomDeviceOSName: String = .mockRandom() + let randomDeviceOSVersion: String = .mockRandom() + + // Given + let builder = ResourceRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + let context: DatadogContext = .mockWith( + clientToken: randomClientToken, + version: randomVersion, + source: randomSource, + sdkVersion: randomSDKVersion, + applicationName: randomApplicationName, + device: .mockWith( + name: randomDeviceName, + osName: randomDeviceOSName, + osVersion: randomDeviceOSVersion + ) + ) + + // When + let request = try builder.request(for: mockEvents, with: context) + + // Then + let contentType = try XCTUnwrap(request.allHTTPHeaderFields?["Content-Type"]) + XCTAssertTrue(contentType.matches(regex: #"multipart\/form-data; boundary=([0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12})"#)) + XCTAssertEqual( + request.allHTTPHeaderFields?["User-Agent"], + """ + \(randomApplicationName)/\(randomVersion) CFNetwork (\(randomDeviceName); \(randomDeviceOSName)/\(randomDeviceOSVersion)) + """ + ) + XCTAssertEqual(request.allHTTPHeaderFields?["DD-API-KEY"], randomClientToken) + XCTAssertEqual(request.allHTTPHeaderFields?["DD-EVP-ORIGIN"], randomSource) + XCTAssertEqual(request.allHTTPHeaderFields?["DD-EVP-ORIGIN-VERSION"], randomSDKVersion) + XCTAssertNotNil(request.allHTTPHeaderFields?["Content-Encoding"], "It must us no compression, because multipart file is compressed separately") + XCTAssertEqual(request.allHTTPHeaderFields?["DD-REQUEST-ID"]?.matches(regex: .uuidRegex), true) + } + + func testItSetsHTTPBodyInExpectedFormat() throws { + // Given + let multipartSpy = MultipartBuilderSpy() + let builder = ResourceRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock(), multipartBuilder: multipartSpy) + + // When + let request = try builder.request(for: mockEvents, with: .mockRandom()) + + // Then + let contentType = try XCTUnwrap(request.allHTTPHeaderFields?["Content-Type"]) + XCTAssertTrue(contentType.matches(regex: "multipart/form-data; boundary=\(multipartSpy.boundary.uuidString)")) + + for i in 0.. String { @@ -53,7 +53,7 @@ class RequestBuilderTests: XCTestCase { func testItSetsCustomIntakeURL() { // Given let randomURL: URL = .mockRandom() - let builder = RequestBuilder(customUploadURL: randomURL, telemetry: TelemetryMock()) + let builder = SegmentRequestBuilder(customUploadURL: randomURL, telemetry: TelemetryMock()) // When func url(for site: DatadogSite) throws -> String { @@ -73,7 +73,7 @@ class RequestBuilderTests: XCTestCase { func testItSetsNoQueryParameters() throws { // Given - let builder = RequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + let builder = SegmentRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) let context: DatadogContext = .mockRandom() // When @@ -94,7 +94,7 @@ class RequestBuilderTests: XCTestCase { let randomDeviceOSVersion: String = .mockRandom() // Given - let builder = RequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + let builder = SegmentRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) let context: DatadogContext = .mockWith( clientToken: randomClientToken, version: randomVersion, @@ -128,22 +128,9 @@ class RequestBuilderTests: XCTestCase { } func testItSetsHTTPBodyInExpectedFormat() throws { - class MultipartBuilderSpy: MultipartFormDataBuilder { - var formFields: [String: String] = [:] - var formFiles: [String: (filename: String, data: Data, mimeType: String)] = [:] - var returnedData: Data = .mockRandom() - - var boundary = UUID() - func addFormField(name: String, value: String) { formFields[name] = value } - func addFormData(name: String, filename: String, data: Data, mimeType: String) { - formFiles[name] = (filename: filename, data: data, mimeType: mimeType) - } - var data: Data { returnedData } - } - // Given let multipartSpy = MultipartBuilderSpy() - let builder = RequestBuilder(customUploadURL: nil, telemetry: TelemetryMock(), multipartBuilder: multipartSpy) + let builder = SegmentRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock(), multipartBuilder: multipartSpy) // When let request = try builder.request(for: mockEvents, with: .mockWith(source: "ios")) @@ -151,8 +138,8 @@ class RequestBuilderTests: XCTestCase { // Then let contentType = try XCTUnwrap(request.allHTTPHeaderFields?["Content-Type"]) XCTAssertTrue(contentType.matches(regex: "multipart/form-data; boundary=\(multipartSpy.boundary.uuidString)")) - XCTAssertEqual(multipartSpy.formFiles["segment"]?.filename, rumContext.sessionID) - XCTAssertEqual(multipartSpy.formFiles["segment"]?.mimeType, "application/octet-stream") + XCTAssertEqual(multipartSpy.formFiles.first?.filename, rumContext.sessionID) + XCTAssertEqual(multipartSpy.formFiles.first?.mimeType, "application/octet-stream") XCTAssertEqual(multipartSpy.formFields["segment"], rumContext.sessionID) XCTAssertEqual(multipartSpy.formFields["application.id"], rumContext.applicationID) XCTAssertEqual(multipartSpy.formFields["view.id"], rumContext.viewID!) @@ -166,7 +153,7 @@ class RequestBuilderTests: XCTestCase { func testWhenBatchDataIsMalformed_itThrows() { // Given - let builder = RequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) + let builder = SegmentRequestBuilder(customUploadURL: nil, telemetry: TelemetryMock()) // When, Then XCTAssertThrowsError(try builder.request(for: [.mockWith(data: "abc".utf8Data)], with: .mockAny())) @@ -175,7 +162,7 @@ class RequestBuilderTests: XCTestCase { func testWhenSourceIsInvalid_itSendsErrorTelemetry() throws { // Given let telemetry = TelemetryMock() - let builder = RequestBuilder(customUploadURL: nil, telemetry: telemetry) + let builder = SegmentRequestBuilder(customUploadURL: nil, telemetry: telemetry) // When _ = try builder.request(for: mockEvents, with: .mockWith(source: "invalid source")) diff --git a/DatadogSessionReplay/Tests/Mocks/CoreGraphicsMocks.swift b/DatadogSessionReplay/Tests/Mocks/CoreGraphicsMocks.swift index 2e9e6d38ff..b40870ab14 100644 --- a/DatadogSessionReplay/Tests/Mocks/CoreGraphicsMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/CoreGraphicsMocks.swift @@ -17,8 +17,8 @@ extension CGFloat: AnyMockable, RandomMockable { return mockRandom(min: .leastNormalMagnitude, max: .greatestFiniteMagnitude) } - static func mockRandom(min: CGFloat, max: CGFloat) -> CGFloat { - return .random(in: min...max) + static func mockRandom(min: CGFloat, max: CGFloat?) -> CGFloat { + return .random(in: min...(max ?? 1_000)) } } @@ -32,13 +32,17 @@ extension CGRect: AnyMockable, RandomMockable { } static func mockRandom( + minX: CGFloat = 0, + maxX: CGFloat? = nil, + minY: CGFloat = 0, + maxY: CGFloat? = nil, minWidth: CGFloat = 0, maxWidth: CGFloat? = nil, minHeight: CGFloat = 0, maxHeight: CGFloat? = nil ) -> CGRect { return .init( - origin: .mockRandom(), + origin: .mockRandom(minX: minX, maxX: maxX, minY: minY, maxY: maxY), size: .mockRandom(minWidth: minWidth, maxWidth: maxWidth, minHeight: minHeight, maxHeight: maxHeight) ) } @@ -50,9 +54,18 @@ extension CGPoint: AnyMockable, RandomMockable { } public static func mockRandom() -> CGPoint { + return mockRandom(minX: -1_000, maxX: 1_000, minY: -1_000, maxY: 1_000) + } + + public static func mockRandom( + minX: CGFloat, + maxX: CGFloat?, + minY: CGFloat, + maxY: CGFloat? + ) -> CGPoint { return .init( - x: .mockRandom(min: -1_000, max: 1_000), - y: .mockRandom(min: -1_000, max: 1_000) + x: .mockRandom(min: minX, max: maxX), + y: .mockRandom(min: minY, max: maxY) ) } } diff --git a/DatadogSessionReplay/Tests/Mocks/MockFeature.swift b/DatadogSessionReplay/Tests/Mocks/MockFeature.swift new file mode 100644 index 0000000000..6b067a5550 --- /dev/null +++ b/DatadogSessionReplay/Tests/Mocks/MockFeature.swift @@ -0,0 +1,24 @@ +/* + * 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. + */ + +#if os(iOS) +import Foundation +import DatadogInternal +import DatadogSessionReplay + +internal class MockFeature: DatadogRemoteFeature { + static var name = "mock-feature" + + var messageReceiver: FeatureMessageReceiver = NOPFeatureMessageReceiver() + var requestBuilder: FeatureRequestBuilder = MockRequestBuilder() +} + +internal class MockRequestBuilder: FeatureRequestBuilder { + func request(for events: [DatadogInternal.Event], with context: DatadogInternal.DatadogContext) throws -> URLRequest { + URLRequest.mockAny() + } +} +#endif diff --git a/DatadogSessionReplay/Tests/Mocks/MockImageDataProvider.swift b/DatadogSessionReplay/Tests/Mocks/MockImageDataProvider.swift index acb7893aeb..7148d6f272 100644 --- a/DatadogSessionReplay/Tests/Mocks/MockImageDataProvider.swift +++ b/DatadogSessionReplay/Tests/Mocks/MockImageDataProvider.swift @@ -5,6 +5,7 @@ */ import UIKit +@_spi(Internal) @testable import DatadogSessionReplay struct MockImageDataProvider: ImageDataProviding { diff --git a/DatadogSessionReplay/Tests/Mocks/MultipartBuilderSpy.swift b/DatadogSessionReplay/Tests/Mocks/MultipartBuilderSpy.swift new file mode 100644 index 0000000000..320d3cb6e5 --- /dev/null +++ b/DatadogSessionReplay/Tests/Mocks/MultipartBuilderSpy.swift @@ -0,0 +1,21 @@ +/* + * 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 +@testable import DatadogSessionReplay + +class MultipartBuilderSpy: MultipartFormDataBuilder { + var formFields: [String: String] = [:] + var formFiles: [(filename: String, data: Data, mimeType: String)] = [] + var returnedData: Data = .mockRandom() + + var boundary = UUID() + func addFormField(name: String, value: String) { formFields[name] = value } + func addFormData(name: String, filename: String, data: Data, mimeType: String) { + formFiles.append((filename: filename, data: data, mimeType: mimeType)) + } + var data: Data { returnedData } +} diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index f22d636336..0e07b22750 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -7,6 +7,7 @@ import Foundation import UIKit import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities @@ -234,6 +235,7 @@ struct ShapeWireframesBuilderMock: NodeWireframesBuilder { } } +@_spi(Internal) extension Node: AnyMockable, RandomMockable { public static func mockAny() -> Node { return mockWith() @@ -333,6 +335,25 @@ class NodeRecorderMock: NodeRecorder { } } +class SessionReplayNodeRecorderMock: SessionReplayNodeRecorder { + var identifier = UUID() + var queriedViews: Set = [] + var queryContexts: [ViewTreeRecordingContext] = [] + var queryContextsByView: [UIView: ViewTreeRecordingContext] = [:] + var resultForView: ((UIView) -> NodeSemantics?)? + + init(resultForView: ((UIView) -> NodeSemantics?)? = nil) { + self.resultForView = resultForView + } + + func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { + queriedViews.insert(view) + queryContexts.append(context) + queryContextsByView[view] = context + return resultForView?(view) + } +} + // MARK: - TouchSnapshot Mocks extension TouchSnapshot: AnyMockable, RandomMockable { diff --git a/DatadogSessionReplay/Tests/Mocks/ResourceMocks.swift b/DatadogSessionReplay/Tests/Mocks/ResourceMocks.swift new file mode 100644 index 0000000000..c9aadad3af --- /dev/null +++ b/DatadogSessionReplay/Tests/Mocks/ResourceMocks.swift @@ -0,0 +1,27 @@ +/* + * 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 TestUtilities +@testable import DatadogSessionReplay + +extension EnrichedResource: RandomMockable { + public static func mockRandom() -> Self { + return .init( + identifier: .mockRandom(), + data: .mockRandom(), + context: .mockRandom() + ) + } +} + +extension EnrichedResource.Context: RandomMockable { + public static func mockRandom() -> Self { + return .init( + .mockRandom() + ) + } +} diff --git a/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift b/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift index d1edca18fd..f11fe44f65 100644 --- a/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift @@ -5,6 +5,7 @@ */ import Foundation +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Mocks/SnapshotProducerMocks.swift b/DatadogSessionReplay/Tests/Mocks/SnapshotProducerMocks.swift index 532cbcc9ca..7eec519784 100644 --- a/DatadogSessionReplay/Tests/Mocks/SnapshotProducerMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/SnapshotProducerMocks.swift @@ -5,6 +5,7 @@ */ import Foundation +@_spi(Internal) @testable import DatadogSessionReplay // MARK: - `ViewTreeSnapshotProducer` Mocks diff --git a/DatadogSessionReplay/Tests/Processor/Diffing/Diff+SRWireframesTests.swift b/DatadogSessionReplay/Tests/Processor/Diffing/Diff+SRWireframesTests.swift index a16b250a32..402feb6e9c 100644 --- a/DatadogSessionReplay/Tests/Processor/Diffing/Diff+SRWireframesTests.swift +++ b/DatadogSessionReplay/Tests/Processor/Diffing/Diff+SRWireframesTests.swift @@ -6,6 +6,7 @@ import XCTest @testable import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay class DiffSRWireframes: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift b/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift index 656ef355c6..1465352dff 100644 --- a/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift +++ b/DatadogSessionReplay/Tests/Processor/Flattening/NodesFlattenerTests.swift @@ -17,7 +17,13 @@ class NodesFlattenerTests: XCTestCase { */ func testFlattenNodes_withNodeThatCoversAnotherNode() { // Given - let frame = CGRect.mockRandom(minWidth: 1, minHeight: 1) + let viewportSize = CGSize.mockRandom(minWidth: 1, minHeight: 1) + let frame = CGRect.mockRandom( + maxX: viewportSize.width - 1, + maxY: viewportSize.height - 1, + minWidth: 1, + minHeight: 1 + ) let coveringNode = Node.mockWith( viewAttributes: .mock(fixture: .opaque), wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) @@ -26,14 +32,16 @@ class NodesFlattenerTests: XCTestCase { viewAttributes: .mockRandom(), wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) - let snapshot = ViewTreeSnapshot.mockWith(nodes: [coveredNode, coveringNode]) + let snapshot = ViewTreeSnapshot.mockWith( + viewportSize: viewportSize, + nodes: [coveredNode, coveringNode] + ) let flattener = NodesFlattener() // When let flattenedNodes = flattener.flattenNodes(in: snapshot) // Then - DDAssertReflectionEqual(flattenedNodes.count, 1) DDAssertReflectionEqual(flattenedNodes, [coveringNode]) } @@ -46,8 +54,13 @@ class NodesFlattenerTests: XCTestCase { */ func testFlattenNodes_withMultipleNodesThatAreCoveredByAnotherNode() { // Given - // set rects - let frame = CGRect.mockRandom() + let viewportSize = CGSize.mockRandom(minWidth: 1, minHeight: 1) + let frame = CGRect.mockRandom( + maxX: viewportSize.width - 1, + maxY: viewportSize.height - 1, + minWidth: 1, + minHeight: 1 + ) let coveringNode = Node.mockWith( viewAttributes: .mock(fixture: .opaque), wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) @@ -64,7 +77,10 @@ class NodesFlattenerTests: XCTestCase { viewAttributes: .mockRandom(), wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: frame) ) - let snapshot = ViewTreeSnapshot.mockWith(nodes: [rootNode, coveredNode1, coveringNode, coveredNode2, coveringNode]) + let snapshot = ViewTreeSnapshot.mockWith( + viewportSize: viewportSize, + nodes: [rootNode, coveredNode1, coveringNode, coveredNode2, coveringNode] + ) let flattener = NodesFlattener() // When @@ -73,4 +89,40 @@ class NodesFlattenerTests: XCTestCase { // Then DDAssertReflectionEqual(flattenedNodes, [coveringNode]) } + + func testFlattenNodes_removesNodeWhenItsOutsideOfViewportSize() { + // Given + let viewportSize = CGSize.mockRandom() + let outsideFrame = CGRect(origin: .init(x: viewportSize.width, y: viewportSize.height), size: .mockRandom()) + let outsideNode = Node.mockWith( + viewAttributes: .mock(fixture: .opaque), + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: outsideFrame) + ) + let snapshot = ViewTreeSnapshot.mockWith(viewportSize: viewportSize, nodes: [outsideNode]) + let flattener = NodesFlattener() + + // When + let flattenedNodes = flattener.flattenNodes(in: snapshot) + + // Then + DDAssertReflectionEqual(flattenedNodes, []) + } + + func testFlattenNodes_doesntRemovesNodeWhenItIntersectsWithViewportSize() { + // Given + let viewportSize = CGSize.mockRandom() + let intersectingFrame = CGRect(origin: .init(x: viewportSize.width - 1, y: viewportSize.height - 1), size: .mockRandom()) + let intersectingNode = Node.mockWith( + viewAttributes: .mock(fixture: .opaque), + wireframesBuilder: ShapeWireframesBuilderMock(wireframeRect: intersectingFrame) + ) + let snapshot = ViewTreeSnapshot.mockWith(viewportSize: viewportSize, nodes: [intersectingNode]) + let flattener = NodesFlattener() + + // When + let flattenedNodes = flattener.flattenNodes(in: snapshot) + + // Then + DDAssertReflectionEqual(flattenedNodes, [intersectingNode]) + } } diff --git a/DatadogSessionReplay/Tests/Processor/ProcessorTests.swift b/DatadogSessionReplay/Tests/Processor/ProcessorTests.swift index edbd711eb0..f8faca5fc1 100644 --- a/DatadogSessionReplay/Tests/Processor/ProcessorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/ProcessorTests.swift @@ -8,13 +8,13 @@ import XCTest import DatadogInternal import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay -private class WriterMock: Writing { +private class WriterMock: RecordWriting { var records: [EnrichedRecord] = [] func write(nextRecord: EnrichedRecord) { records.append(nextRecord) } - func startWriting(to core: DatadogCoreProtocol) {} } class ProcessorTests: XCTestCase { @@ -130,8 +130,8 @@ class ProcessorTests: XCTestCase { XCTAssertTrue(enrichedRecords[0].records[1].isFocusRecord) XCTAssertTrue(enrichedRecords[0].records[2].isFullSnapshotRecord && enrichedRecords[0].hasFullSnapshot) - XCTAssertEqual(enrichedRecords[1].records.count, 2, "It should follow with two 'incremental snapshot' records") - XCTAssertTrue(enrichedRecords[1].records[0].isIncrementalSnapshotRecord) + XCTAssertEqual(enrichedRecords[1].records.count, 2, "It should follow with 'full snapshot' → 'incremental snapshot' records") + XCTAssertTrue(enrichedRecords[1].records[0].isFullSnapshotRecord && enrichedRecords[1].hasFullSnapshot) XCTAssertTrue(enrichedRecords[1].records[1].isIncrementalSnapshotRecord) XCTAssertEqual(enrichedRecords[1].records[1].incrementalSnapshot?.viewportResizeData?.height, 100) XCTAssertEqual(enrichedRecords[1].records[1].incrementalSnapshot?.viewportResizeData?.width, 200) @@ -279,7 +279,7 @@ class ProcessorTests: XCTestCase { // MARK: - `ViewTreeSnapshot` generation - private let snapshotBuilder = ViewTreeSnapshotBuilder() + private let snapshotBuilder = ViewTreeSnapshotBuilder(additionalNodeRecorders: []) private func generateViewTreeSnapshot(for viewTree: UIView, date: Date, rumContext: RUMContext) -> ViewTreeSnapshot { snapshotBuilder.createSnapshot(of: viewTree, with: .init(privacy: .allow, rumContext: rumContext, date: date)) @@ -287,7 +287,27 @@ class ProcessorTests: XCTestCase { private func generateSimpleViewTree() -> UIView { let root = UIView.mock(withFixture: .visible(.someAppearance)) + root.frame = .mockRandom( + minX: 0, + maxX: 0, + minY: 0, + maxY: 0, + minWidth: 1_000, + maxWidth: 2_000, + minHeight: 1_000, + maxHeight: 2_000 + ) let child = UIView.mock(withFixture: .visible(.someAppearance)) + child.frame = .mockRandom( + minX: 0, + maxX: 100, + minY: 0, + maxY: 100, + minWidth: 100, + maxWidth: 200, + minHeight: 100, + maxHeight: 200 + ) root.addSubview(child) return root } diff --git a/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift b/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift index 5aedcf6355..ec47cdede1 100644 --- a/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift +++ b/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift @@ -6,6 +6,7 @@ import XCTest import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay class RecordsBuilderTests: XCTestCase { @@ -45,6 +46,38 @@ class RecordsBuilderTests: XCTestCase { DDAssertReflectionEqual(mutations.adds[0].wireframe, next[2]) } + func testWhenNextWireframesAddsNewRoot_itCreatesFullSnapshotRecord() throws { + let builder = RecordsBuilder(telemetry: TelemetryMock()) + + // Given + let previous: [SRWireframe] = [.mockRandomWith(id: 0), .mockRandomWith(id: 1)] + let next: [SRWireframe] = [.mockRandomWith(id: 2)] + previous + + // When + let record = builder.createIncrementalSnapshotRecord(from: .mockAny(), with: next, lastWireframes: previous) + + // Then + let fullRecord = try XCTUnwrap(record?.fullSnapshot) + DDAssertReflectionEqual(fullRecord.data.wireframes, next) + } + + // This case does not need a full snapshot for the player to work, but adding a + // test documents the behavior if we want to change it. + func testWhenNextWireframesDeletesRoot_itCreatesFullSnapshotRecord() throws { + let builder = RecordsBuilder(telemetry: TelemetryMock()) + + // Given + let previous: [SRWireframe] = [.mockRandomWith(id: 0), .mockRandomWith(id: 1)] + let next: [SRWireframe] = [.mockRandomWith(id: 1)] + + // When + let record = builder.createIncrementalSnapshotRecord(from: .mockAny(), with: next, lastWireframes: previous) + + // Then + let fullRecord = try XCTUnwrap(record?.fullSnapshot) + DDAssertReflectionEqual(fullRecord.data.wireframes, next) + } + func testWhenWireframesAreNotConsistent_itFallbacksToFullSnapshotRecordAndSendsErrorTelemetry() throws { let telemetry = TelemetryMock() let builder = RecordsBuilder(telemetry: telemetry) diff --git a/DatadogSessionReplay/Tests/Recorder/PrivacyLevelTests.swift b/DatadogSessionReplay/Tests/Recorder/PrivacyLevelTests.swift index c5331f3a05..5a09bcc267 100644 --- a/DatadogSessionReplay/Tests/Recorder/PrivacyLevelTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/PrivacyLevelTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class PrivacyLevelTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/RecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/RecorderTests.swift index cace0eb328..1365f467f0 100644 --- a/DatadogSessionReplay/Tests/Recorder/RecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/RecorderTests.swift @@ -5,13 +5,14 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities class RecorderTests: XCTestCase { func testAfterCapturingSnapshot_itIsPassesToProcessor() { - let mockViewTreeSnapshots: [ViewTreeSnapshot] = .mockRandom() - let mockTouchSnapshots: [TouchSnapshot] = .mockRandom() + let mockViewTreeSnapshots: [ViewTreeSnapshot] = .mockRandom(count: 1) + let mockTouchSnapshots: [TouchSnapshot] = .mockRandom(count: 1) let processor = ProcessorSpy() // Given @@ -78,4 +79,30 @@ class RecorderTests: XCTestCase { """ ) } + + func testWhenCapturingSnapshots_itUsesAdditionalNodeRecorders() throws { + let recorderContext: Recorder.Context = .mockRandom() + let additionalNodeRecorder = SessionReplayNodeRecorderMock() + let windowObserver = AppWindowObserverMock() + let viewTreeSnapshotProducer = WindowViewTreeSnapshotProducer( + windowObserver: windowObserver, + snapshotBuilder: ViewTreeSnapshotBuilder(additionalNodeRecorders: [additionalNodeRecorder]) + ) + let touchSnapshotProducer = TouchSnapshotProducerMock() + + // Given + let recorder = Recorder( + uiApplicationSwizzler: .mockAny(), + viewTreeSnapshotProducer: viewTreeSnapshotProducer, + touchSnapshotProducer: touchSnapshotProducer, + snapshotProcessor: ProcessorSpy(), + telemetry: TelemetryMock() + ) + // When + recorder.captureNextRecord(recorderContext) + + // Then + let queryContext = try XCTUnwrap(additionalNodeRecorder.queryContexts.first) + XCTAssertEqual(queryContext.recorder, recorderContext) + } } diff --git a/DatadogSessionReplay/Tests/Recorder/RecordingCoordinatorTests.swift b/DatadogSessionReplay/Tests/Recorder/RecordingCoordinatorTests.swift index c5eeed14f4..a6bb082b33 100644 --- a/DatadogSessionReplay/Tests/Recorder/RecordingCoordinatorTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/RecordingCoordinatorTests.swift @@ -6,6 +6,7 @@ import XCTest import DatadogInternal +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift b/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift index 7994c6140d..812c1dca65 100644 --- a/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/Utilties/ImageDataProviderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class ImageDataProviderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeIDGeneratorTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeIDGeneratorTests.swift index eaafd04524..fc37d10ad3 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeIDGeneratorTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeIDGeneratorTests.swift @@ -6,6 +6,7 @@ import XCTest import UIKit +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift index 39460a8a98..ec39138502 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UIDatePickerRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift index dabd8e059b..727176a7de 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay import TestUtilities diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewWireframesBuilderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewWireframesBuilderTests.swift index feaaf3b0d7..326e9ddb6f 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewWireframesBuilderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewWireframesBuilderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UIImageViewWireframesBuilderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift index 6e032f449c..eca8bd4ad0 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift @@ -6,6 +6,7 @@ import XCTest import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay // swiftlint:disable opening_brace diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift index cbedfe912f..f177c674bc 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UINavigationBarRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift index 7703ffb398..521e5f5ccd 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UIPickerViewRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift index ac7cb0c491..b5c248538a 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UISegmentRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift index 32c004ae96..b68ed142af 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISliderRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UISliderRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift index 054cb11645..785eea0b45 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIStepperRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UIStepperRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift index 7067c8381f..153d30a261 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISwitchRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UISwitchRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift index b411cc6520..c0ef84b6e5 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UITabBarRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift index 3d7f2775e5..cb4f6ac5b6 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift @@ -6,6 +6,7 @@ import XCTest import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay // swiftlint:disable opening_brace diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift index a516d762b8..84a024f7d5 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 88b416f4a0..1937ed9486 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay class UIViewRecorderTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift index 5844b94d20..a6a90b6d00 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift @@ -8,6 +8,7 @@ import XCTest import WebKit import SwiftUI import SafariServices +@_spi(Internal) @testable import DatadogSessionReplay @available(iOS 13.0, *) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index e6c08783ec..c29f44612d 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift @@ -7,6 +7,7 @@ import XCTest import SafariServices +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContextTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContextTests.swift index 9134111ea7..ca4cb28b7a 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContextTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContextTests.swift @@ -6,6 +6,7 @@ import XCTest import SafariServices +@_spi(Internal) @testable import DatadogSessionReplay class ViewTreeRecordingContextTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift index 175b63a813..ddd6f9d6b8 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilderTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities @@ -48,4 +49,22 @@ class ViewTreeSnapshotBuilderTests: XCTestCase { // Then XCTAssertGreaterThan(snapshot.date, now) } + + func testWhenQueryingNodeRecorders_itCallsAdditionalNodeRecorders() throws { + // Given + let view = UIView(frame: .mockRandom()) + let randomRecorderContext: Recorder.Context = .mockRandom() + let additionalNodeRecorder = SessionReplayNodeRecorderMock(resultForView: { _ in nil }) + let builder = ViewTreeSnapshotBuilder(additionalNodeRecorders: [additionalNodeRecorder]) + + // When + let snapshot = builder.createSnapshot(of: view, with: randomRecorderContext) + + // Then + XCTAssertEqual(snapshot.context, randomRecorderContext) + + let queryContext = try XCTUnwrap(additionalNodeRecorder.queryContexts.first) + XCTAssertTrue(queryContext.coordinateSpace === view) + XCTAssertEqual(queryContext.recorder, randomRecorderContext) + } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index d42ed2b6fa..c1b07e3abe 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -5,6 +5,7 @@ */ import XCTest +@_spi(Internal) @testable import DatadogSessionReplay @testable import TestUtilities diff --git a/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift b/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift index b5c64f3d3c..74d4eaf65d 100644 --- a/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift @@ -5,7 +5,9 @@ */ import XCTest -import DatadogSessionReplay +@_spi(Internal) +@testable import DatadogSessionReplay +@testable import TestUtilities class SessionReplayConfigurationTests: XCTestCase { func testDefaultConfiguration() { @@ -18,5 +20,19 @@ class SessionReplayConfigurationTests: XCTestCase { XCTAssertEqual(config.replaySampleRate, random) XCTAssertEqual(config.defaultPrivacyLevel, .mask) XCTAssertNil(config.customEndpoint) + XCTAssertEqual(config._additionalNodeRecorders.count, 0) + } + + func testConfigurationWithAdditionalNodeRecorders() { + let random: Float = .mockRandom(min: 0, max: 100) + let mockNodeRecorder = SessionReplayNodeRecorderMock() + + // When + var config = SessionReplay.Configuration(replaySampleRate: random) + config.setAdditionalNodeRecorders([mockNodeRecorder]) + + // Then + XCTAssertEqual(config._additionalNodeRecorders.count, 1) + XCTAssertEqual(config._additionalNodeRecorders[0].identifier, mockNodeRecorder.identifier) } } diff --git a/DatadogSessionReplay/Tests/SessionReplayTests.swift b/DatadogSessionReplay/Tests/SessionReplayTests.swift index 0838621335..b94c948d00 100644 --- a/DatadogSessionReplay/Tests/SessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayTests.swift @@ -7,21 +7,22 @@ import XCTest import TestUtilities @testable import DatadogInternal +@_spi(Internal) @testable import DatadogSessionReplay class SessionReplayTests: XCTestCase { - private var core: SingleFeatureCoreMock! // swiftlint:disable:this implicitly_unwrapped_optional + private var core: FeatureRegistrationCoreMock! // swiftlint:disable:this implicitly_unwrapped_optional private var config: SessionReplay.Configuration! // swiftlint:disable:this implicitly_unwrapped_optional override func setUpWithError() throws { - core = SingleFeatureCoreMock() + core = FeatureRegistrationCoreMock() config = SessionReplay.Configuration(replaySampleRate: 100) } override func tearDown() { core = nil config = nil - XCTAssertEqual(SingleFeatureCoreMock.referenceCount, 0) + XCTAssertEqual(FeatureRegistrationCoreMock.referenceCount, 0) } func testWhenEnabled_itRegistersSessionReplayFeature() { @@ -30,6 +31,7 @@ class SessionReplayTests: XCTestCase { // Then XCTAssertNotNil(core.get(feature: SessionReplayFeature.self)) + XCTAssertNotNil(core.get(feature: ResourcesFeature.self)) } func testWhenEnabledInNOPCore_itPrintsError() { @@ -59,7 +61,9 @@ class SessionReplayTests: XCTestCase { let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) XCTAssertEqual(sr.recordingCoordinator.sampler.samplingRate, 42) XCTAssertEqual(sr.recordingCoordinator.privacy, .mask) - XCTAssertNil((sr.requestBuilder as? RequestBuilder)?.customUploadURL) + XCTAssertNil((sr.requestBuilder as? SegmentRequestBuilder)?.customUploadURL) + let r = try XCTUnwrap(core.get(feature: ResourcesFeature.self)) + XCTAssertNil((r.requestBuilder as? ResourceRequestBuilder)?.customUploadURL) } func testWhenEnabledWithReplaySampleRate() throws { @@ -95,7 +99,7 @@ class SessionReplayTests: XCTestCase { // Then let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) - XCTAssertEqual((sr.requestBuilder as? RequestBuilder)?.customUploadURL, random) + XCTAssertEqual((sr.requestBuilder as? SegmentRequestBuilder)?.customUploadURL, random) } func testWhenEnabledWithDebugSDKArgument() throws { @@ -124,16 +128,4 @@ class SessionReplayTests: XCTestCase { let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) XCTAssertEqual(sr.recordingCoordinator.sampler.samplingRate, random) } - - // MARK: - Behaviour Tests - - func testWhenEnabled_itWritesEventsToCore() throws { - // When - SessionReplay.enable(with: config, in: core) - - // Then - let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) - sr.writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) - XCTAssertEqual(core.events.count, 1) - } } diff --git a/DatadogSessionReplay/Tests/Writer/Models/EnrichedRecordTests.swift b/DatadogSessionReplay/Tests/Writer/Models/EnrichedRecordTests.swift index f7d6534adf..3b726427f5 100644 --- a/DatadogSessionReplay/Tests/Writer/Models/EnrichedRecordTests.swift +++ b/DatadogSessionReplay/Tests/Writer/Models/EnrichedRecordTests.swift @@ -6,6 +6,7 @@ import XCTest @testable import TestUtilities +@_spi(Internal) @testable import DatadogSessionReplay class EnrichedRecordTests: XCTestCase { diff --git a/DatadogSessionReplay/Tests/Writer/RecordsWriterTests.swift b/DatadogSessionReplay/Tests/Writer/RecordsWriterTests.swift new file mode 100644 index 0000000000..42c938fbac --- /dev/null +++ b/DatadogSessionReplay/Tests/Writer/RecordsWriterTests.swift @@ -0,0 +1,61 @@ +/* + * 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 + +@testable import DatadogSessionReplay +@testable import TestUtilities + +// swiftlint:disable empty_xctest_method +class RecordsWriterTests: XCTestCase { + func testWhenFeatureScopeIsConnected_itWritesRecordsToCore() { + // Given + let core = PassthroughCoreMock() + + // When + let writer = RecordWriter(core: core) + + // Then + writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) + writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) + writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) + + XCTAssertEqual(core.events(ofType: EnrichedRecord.self).count, 3) + } + + func testWhenFeatureScopeIsNotConnected_itDoesNotWriteRecordsToCore() throws { + // Given + let core = SingleFeatureCoreMock() + let feature = MockFeature() + try core.register(feature: feature) + + // When + let writer = RecordWriter(core: core) + + // Then + writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) + + XCTAssertEqual(core.events(ofType: EnrichedRecord.self).count, 0) + } + + func testWhenSucceedingRecordsDescribeDifferentRUMViews_itWritesThemToSeparateBatches() throws { + // Given + let forceNewBatchExpectation = expectation(description: "Should force new batch on view-id change") + forceNewBatchExpectation.expectedFulfillmentCount = 2 + let core = PassthroughCoreMock(forceNewBatchExpectation: forceNewBatchExpectation) + + // When + let writer = RecordWriter(core: core) + + // Then + writer.write(nextRecord: EnrichedRecord(context: .mockWith(rumContext: .mockWith(viewID: "view1")), records: .mockRandom())) + writer.write(nextRecord: EnrichedRecord(context: .mockWith(rumContext: .mockWith(viewID: "view2")), records: .mockRandom())) + + XCTAssertEqual(core.events(ofType: EnrichedRecord.self).count, 2) + waitForExpectations(timeout: 0.5, handler: nil) + } +} +// swiftlint:enable empty_xctest_method diff --git a/DatadogSessionReplay/Tests/Writer/ResourcesWriterTests.swift b/DatadogSessionReplay/Tests/Writer/ResourcesWriterTests.swift new file mode 100644 index 0000000000..e0d8345bf6 --- /dev/null +++ b/DatadogSessionReplay/Tests/Writer/ResourcesWriterTests.swift @@ -0,0 +1,42 @@ +/* + * 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 + +@testable import DatadogSessionReplay +@testable import TestUtilities + +class ResourcesWriterTests: XCTestCase { + func testWhenFeatureScopeIsConnected_itWritesResourcesToCore() { + // Given + let core = PassthroughCoreMock() + + // When + let writer = ResourcesWriter(core: core) + + // Then + writer.write(resources: [.mockRandom()]) + writer.write(resources: [.mockRandom()]) + writer.write(resources: [.mockRandom()]) + + XCTAssertEqual(core.events(ofType: [EnrichedResource].self).count, 3) + } + + func testWhenFeatureScopeIsNotConnected_itDoesNotWriteRecordsToCore() throws { + // Given + let core = SingleFeatureCoreMock() + let feature = MockFeature() + try core.register(feature: feature) + + // When + let writer = ResourcesWriter(core: core) + + // Then + writer.write(resources: [.mockRandom()]) + + XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 0) + } +} diff --git a/DatadogSessionReplay/Tests/Writer/WriterTests.swift b/DatadogSessionReplay/Tests/Writer/WriterTests.swift deleted file mode 100644 index 6020ee29e1..0000000000 --- a/DatadogSessionReplay/Tests/Writer/WriterTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import XCTest - -@testable import DatadogSessionReplay -@testable import TestUtilities - -// swiftlint:disable empty_xctest_method -class WriterTests: XCTestCase { - func testWhenFeatureScopeIsConnected_itWritesRecordsToCore() { - // Given - let core = PassthroughCoreMock() - let writer = Writer() - - // When - writer.startWriting(to: core) - - // Then - writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) - writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) - writer.write(nextRecord: EnrichedRecord(context: .mockRandom(), records: .mockRandom())) - - XCTAssertEqual(core.events(ofType: EnrichedRecord.self).count, 3) - } - - func testWhenFeatureScopeIsNotConnected_itDoesNotWriteRecordsToCore() { - // TODO: RUMM-2690 - // Implementing this test requires creating mocks for `DatadogContext` (passed in `FeatureScope`), - // which is yet not possible as we lack separate, shared module to facilitate tests. - } - - func testWhenSucceedingRecordsDescribeDifferentRUMViews_itWritesThemToSeparateBatches() { - // TODO: RUMM-2690 - // Implementing this test requires creating mocks for `DatadogContext` (passed in `FeatureScope`), - // which is yet not possible as we lack separate, shared module to facilitate tests. - } -} -// swiftlint:enable empty_xctest_method diff --git a/DatadogTrace.podspec b/DatadogTrace.podspec index f8c0df7807..4ea10fbfe7 100644 --- a/DatadogTrace.podspec +++ b/DatadogTrace.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogTrace" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog Trace Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogWebViewTracking.podspec b/DatadogWebViewTracking.podspec index ed52a2986f..35d82abb4e 100644 --- a/DatadogWebViewTracking.podspec +++ b/DatadogWebViewTracking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogWebViewTracking" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog WebView Tracking Module." s.homepage = "https://www.datadoghq.com" diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/CrashReporting/CrashReportingWithRUMScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/CrashReporting/CrashReportingWithRUMScenarioTests.swift index 41f4c69d6e..88021749e4 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/CrashReporting/CrashReportingWithRUMScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/CrashReporting/CrashReportingWithRUMScenarioTests.swift @@ -45,7 +45,7 @@ class CrashReportingWithRUMScenarioTests: IntegrationTests, RUMCommonAsserts { let recordedRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in let sessions = try RUMSessionMatcher.sessions(maxCount: 2, from: requests) let thereAreTwoSessions = sessions.count == 2 - let firstSessionHasError = sessions.first?.viewVisits.first?.errorEvents.count == 1 + let firstSessionHasError = sessions.first?.errorEventMatchers.count == 1 return thereAreTwoSessions && firstSessionHasError } @@ -54,23 +54,28 @@ class CrashReportingWithRUMScenarioTests: IntegrationTests, RUMCommonAsserts { let sessions = try RUMSessionMatcher.sessions(maxCount: 2, from: recordedRequests) .sorted { session1, session2 in // Sort sessions by their "application_start" action date - return session1.applicationLaunchView!.actionEvents[0].date < session2.applicationLaunchView!.actionEvents[0].date + return session1.views[0].actionEvents[0].date < session2.views[0].actionEvents[0].date } let crashedSession = try XCTUnwrap(sessions.first) + sendCIAppLog("Crashed session: \n\(crashedSession)") - XCTAssertEqual(crashedSession.viewVisits[0].name, "Runner.CrashReportingViewController") + let initialView = crashedSession.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + XCTAssertEqual(crashedSession.views[1].name, "Runner.CrashReportingViewController") XCTAssertEqual( - crashedSession.viewVisits[0].viewEvents.last?.view.crash?.count, + crashedSession.views[1].viewEvents.last?.view.crash?.count, 1, "The RUM View should count the crash." ) XCTAssertEqual( - crashedSession.viewVisits[0].errorEvents.count, + crashedSession.views[1].errorEvents.count, 1, "The RUM View should count 1 error in total." ) - let crashRUMError = try XCTUnwrap(crashedSession.viewVisits[0].errorEvents.last) + let crashRUMError = try XCTUnwrap(crashedSession.views[1].errorEvents.last) XCTAssertEqual(crashRUMError.version, "1.0") XCTAssertEqual(crashRUMError.buildVersion, "1") diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMCommonAsserts.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMCommonAsserts.swift index c1c9916408..9e17831dad 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMCommonAsserts.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMCommonAsserts.swift @@ -58,7 +58,7 @@ extension RUMSessionMatcher { .flatMap { request in try RUMEventMatcher.fromNewlineSeparatedJSONObjectsData(request.httpBody, eventsPatch: eventsPatch) } .filter { event in try event.eventType() != "telemetry" } let sessionMatchers = try RUMSessionMatcher.groupMatchersBySessions(eventMatchers).sorted(by: { - return $0.viewVisits.first?.viewEvents.first?.date ?? 0 < $1.viewVisits.first?.viewEvents.first?.date ?? 0 + return $0.views.first?.viewEvents.first?.date ?? 0 < $1.views.first?.viewEvents.first?.date ?? 0 }) if sessionMatchers.count > maxCount { @@ -73,16 +73,16 @@ extension RUMSessionMatcher { return sessionMatchers } - class func assertViewWasEventuallyInactive(_ viewVisit: ViewVisit) { - XCTAssertFalse(try XCTUnwrap(viewVisit.viewEvents.last?.view.isActive)) + class func assertViewWasEventuallyInactive(_ view: View) { + XCTAssertFalse(try XCTUnwrap(view.viewEvents.last?.view.isActive)) } /// Checks if RUM session has ended by: /// - checking if it contains "end view" added in response to `ExampleApplication.endRUMSession()`; /// - checking if all other views are marked as "inactive" (meaning they ended up processing their resources). func hasEnded() -> Bool { - let hasEndView = viewVisits.last?.name == Environment.Constants.rumSessionEndViewName - let hasSomeActiveView = viewVisits.contains(where: { $0.viewEvents.last?.view.isActive == true }) + let hasEndView = views.last?.name == Environment.Constants.rumSessionEndViewName + let hasSomeActiveView = views.contains(where: { $0.viewEvents.last?.view.isActive == true }) return hasEndView && !hasSomeActiveView } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMManualInstrumentationScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMManualInstrumentationScenarioTests.swift index 0a1a7d3881..e27531771f 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMManualInstrumentationScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMManualInstrumentationScenarioTests.swift @@ -56,10 +56,11 @@ class RUMManualInstrumentationScenarioTests: IntegrationTests, RUMCommonAsserts let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let launchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(launchView.actionEvents[0].action.type, .applicationStart) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) - let view1 = session.viewVisits[0] + let view1 = session.views[1] XCTAssertEqual(view1.name, "SendRUMFixture1View") XCTAssertEqual(view1.path, "Runner.SendRUMFixture1ViewController") XCTAssertNotNil(view1.viewEvents.last?.device) @@ -104,7 +105,7 @@ class RUMManualInstrumentationScenarioTests: IntegrationTests, RUMCommonAsserts XCTAssertGreaterThan(firstInteractionTiming, 0) XCTAssertLessThan(firstInteractionTiming, 5_000_000_000) - let view2 = session.viewVisits[1] + let view2 = session.views[2] XCTAssertEqual(view2.name, "SendRUMFixture2View") XCTAssertEqual(view2.path, "Runner.SendRUMFixture2ViewController") XCTAssertNotNil(view2.viewEvents.last?.device) @@ -121,7 +122,7 @@ class RUMManualInstrumentationScenarioTests: IntegrationTests, RUMCommonAsserts XCTAssertEqual((errorFeatureFlags.featureFlagsInfo["mock_flag_b"] as? AnyCodable)?.value as? String, "mock_value") RUMSessionMatcher.assertViewWasEventuallyInactive(view2) - let view3 = session.viewVisits[2] + let view3 = session.views[3] XCTAssertEqual(view3.name, "SendRUMFixture3View") XCTAssertEqual(view3.path, "fixture3-vc") XCTAssertNotNil(view3.viewEvents.last?.device) diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMMobileVitalsScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMMobileVitalsScenarioTests.swift index 611941d68b..4d2372d4c0 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMMobileVitalsScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMMobileVitalsScenarioTests.swift @@ -54,7 +54,8 @@ class RUMMobileVitalsScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let lastViewEvent = try XCTUnwrap(session.viewVisits[0].viewEvents.last) + let views = try session.views.dropApplicationLaunchView() + let lastViewEvent = try XCTUnwrap(views.first?.viewEvents.last) let cpuTicksPerSecond = try XCTUnwrap(lastViewEvent.view.cpuTicksPerSecond) XCTAssertGreaterThan(cpuTicksPerSecond, 0.0) @@ -62,7 +63,7 @@ class RUMMobileVitalsScenarioTests: IntegrationTests, RUMCommonAsserts { let refreshRateAverage = try XCTUnwrap(lastViewEvent.view.refreshRateAverage) XCTAssertGreaterThan(refreshRateAverage, 0.0) - let longTaskEvents = session.viewVisits[0].longTaskEvents + let longTaskEvents = views[0].longTaskEvents XCTAssertEqual(longTaskEvents.count, 2) let longTask1 = longTaskEvents[0] @@ -98,7 +99,14 @@ class RUMMobileVitalsScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let lastViewEvent = try XCTUnwrap(session.viewVisits[1].viewEvents.last) - XCTAssertNil(lastViewEvent.view.cpuTicksPerSecond) + let views = try session.views.dropApplicationLaunchView() + let lastViewEvent = try XCTUnwrap(views[1].viewEvents.last) + let oneSecond: TimeInterval = 1 + + // When + XCTAssertLessThan(lastViewEvent.view.timeSpent, oneSecond.toInt64Nanoseconds, "When view lasts less than 1s") + + // Then + XCTAssertNil(lastViewEvent.view.cpuTicksPerSecond, "It should have no CPU ticks reported") } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMModalViewsScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMModalViewsScenarioTests.swift index db90680cdf..2b1c01cace 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMModalViewsScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMModalViewsScenarioTests.swift @@ -63,45 +63,45 @@ class RUMModalViewsScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let launchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(launchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(launchView.actionEvents[0].action.loadingTime!, 0) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + XCTAssertGreaterThan(initialView.actionEvents[0].action.loadingTime!, 0) - let visits = session.viewVisits - XCTAssertEqual(visits[0].name, "Screen") - XCTAssertEqual(visits[0].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[0]) // go to modal "Modal" + XCTAssertEqual(session.views[1].name, "Screen") + XCTAssertEqual(session.views[1].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[1]) // go to modal "Modal" - XCTAssertEqual(visits[1].name, "Modal") - XCTAssertEqual(visits[1].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[1]) // dismiss to "Screen" + XCTAssertEqual(session.views[2].name, "Modal") + XCTAssertEqual(session.views[2].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[2]) // dismiss to "Screen" - XCTAssertEqual(visits[2].name, "Screen") - XCTAssertEqual(visits[2].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[2]) // go to modal "Modal" + XCTAssertEqual(session.views[3].name, "Screen") + XCTAssertEqual(session.views[3].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[3]) // go to modal "Modal" - XCTAssertEqual(visits[3].name, "Modal") - XCTAssertEqual(visits[3].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[3]) // interactive dismiss to "Screen" + XCTAssertEqual(session.views[4].name, "Modal") + XCTAssertEqual(session.views[4].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[4]) // interactive dismiss to "Screen" - XCTAssertEqual(visits[4].name, "Screen") - XCTAssertEqual(visits[4].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[4]) // go to modal "Modal" + XCTAssertEqual(session.views[5].name, "Screen") + XCTAssertEqual(session.views[5].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[5]) // go to modal "Modal" - XCTAssertEqual(visits[5].name, "Modal") - XCTAssertEqual(visits[5].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[5]) // interactive and cancelled dismiss, stay on "Modal" + XCTAssertEqual(session.views[6].name, "Modal") + XCTAssertEqual(session.views[6].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[6]) // interactive and cancelled dismiss, stay on "Modal" - XCTAssertEqual(visits[6].name, "Screen") - XCTAssertEqual(visits[6].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[6]) // interactive and cancelled dismiss, stay on "Modal" + XCTAssertEqual(session.views[7].name, "Screen") + XCTAssertEqual(session.views[7].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[7]) // interactive and cancelled dismiss, stay on "Modal" - XCTAssertEqual(visits[7].name, "Modal") - XCTAssertEqual(visits[7].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[7]) // dismiss to "Screen" + XCTAssertEqual(session.views[8].name, "Modal") + XCTAssertEqual(session.views[8].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[8]) // dismiss to "Screen" - XCTAssertEqual(visits[8].name, "Screen") - XCTAssertEqual(visits[8].path, "Runner.RUMMVSViewController") + XCTAssertEqual(session.views[9].name, "Screen") + XCTAssertEqual(session.views[9].path, "Runner.RUMMVSViewController") } func testRUMUntrackedModalViewsScenario() throws { @@ -141,40 +141,40 @@ class RUMModalViewsScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let applicationLaunchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(applicationLaunchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(applicationLaunchView.actionEvents[0].action.loadingTime!, 0) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + XCTAssertGreaterThan(initialView.actionEvents[0].action.loadingTime!, 0) - let visits = session.viewVisits - XCTAssertEqual(visits[0].name, "Screen") - XCTAssertEqual(visits[0].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[0]) // go to modal "Modal" + XCTAssertEqual(session.views[1].name, "Screen") + XCTAssertEqual(session.views[1].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[1]) // go to modal "Modal" - XCTAssertEqual(visits[1].name, "Modal") - XCTAssertEqual(visits[1].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[1]) // dismiss to "Screen" + XCTAssertEqual(session.views[2].name, "Modal") + XCTAssertEqual(session.views[2].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[2]) // dismiss to "Screen" - XCTAssertEqual(visits[2].name, "Screen") - XCTAssertEqual(visits[2].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[2]) // go to modal "Modal", which is untracked + XCTAssertEqual(session.views[3].name, "Screen") + XCTAssertEqual(session.views[3].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[3]) // go to modal "Modal", which is untracked - XCTAssertEqual(visits[3].name, "Screen") // Screen restarts properly - XCTAssertEqual(visits[3].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[4]) // go to modal "Modal" + XCTAssertEqual(session.views[4].name, "Screen") // Screen restarts properly + XCTAssertEqual(session.views[4].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[4]) // go to modal "Modal" - XCTAssertEqual(visits[4].name, "Modal") - XCTAssertEqual(visits[4].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[5]) // interactive and cancelled dismiss, stay on "Modal" + XCTAssertEqual(session.views[5].name, "Modal") + XCTAssertEqual(session.views[5].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[5]) // interactive and cancelled dismiss, stay on "Modal" - XCTAssertEqual(visits[5].name, "Screen") - XCTAssertEqual(visits[5].path, "Runner.RUMMVSViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[6]) // interactive and cancelled dismiss, stay on "Modal" + XCTAssertEqual(session.views[6].name, "Screen") + XCTAssertEqual(session.views[6].path, "Runner.RUMMVSViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[6]) // interactive and cancelled dismiss, stay on "Modal" - XCTAssertEqual(visits[6].name, "Modal") - XCTAssertEqual(visits[6].path, "Runner.RUMMVSModalViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[7]) // dismiss to "Screen" + XCTAssertEqual(session.views[7].name, "Modal") + XCTAssertEqual(session.views[7].path, "Runner.RUMMVSModalViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[7]) // dismiss to "Screen" - XCTAssertEqual(visits[7].name, "Screen") - XCTAssertEqual(visits[7].path, "Runner.RUMMVSViewController") + XCTAssertEqual(session.views[8].name, "Screen") + XCTAssertEqual(session.views[8].path, "Runner.RUMMVSViewController") } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMNavigationControllerScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMNavigationControllerScenarioTests.swift index 6da133fea1..6e0b61be89 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMNavigationControllerScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMNavigationControllerScenarioTests.swift @@ -60,40 +60,39 @@ class RUMNavigationControllerScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let applicationLaunchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(applicationLaunchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(applicationLaunchView.actionEvents[0].action.loadingTime!, 0) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) - let visits = session.viewVisits - XCTAssertEqual(visits[0].name, "Screen1") - XCTAssertEqual(visits[0].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[0]) // go to "Screen2" + XCTAssertEqual(session.views[1].name, "Screen1") + XCTAssertEqual(session.views[1].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[1]) // go to "Screen2" - XCTAssertEqual(session.viewVisits[1].name, "Screen2") - XCTAssertEqual(session.viewVisits[1].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[1])// go to "Screen3" + XCTAssertEqual(session.views[2].name, "Screen2") + XCTAssertEqual(session.views[2].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[2])// go to "Screen3" - XCTAssertEqual(session.viewVisits[2].name, "Screen3") - XCTAssertEqual(session.viewVisits[2].path, "Runner.RUMNCSScreen3ViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[2])// go to "Screen4" + XCTAssertEqual(session.views[3].name, "Screen3") + XCTAssertEqual(session.views[3].path, "Runner.RUMNCSScreen3ViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[3])// go to "Screen4" - XCTAssertEqual(session.viewVisits[3].name, "Screen4") - XCTAssertEqual(session.viewVisits[3].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[3])// go to "Screen3" + XCTAssertEqual(session.views[4].name, "Screen4") + XCTAssertEqual(session.views[4].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[4])// go to "Screen3" - XCTAssertEqual(session.viewVisits[4].name, "Screen3") - XCTAssertEqual(session.viewVisits[4].path, "Runner.RUMNCSScreen3ViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[4])// go to "Screen1" + XCTAssertEqual(session.views[5].name, "Screen3") + XCTAssertEqual(session.views[5].path, "Runner.RUMNCSScreen3ViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[5])// go to "Screen1" - XCTAssertEqual(session.viewVisits[5].name, "Screen1") - XCTAssertEqual(session.viewVisits[5].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[5])// go to "Screen2" + XCTAssertEqual(session.views[6].name, "Screen1") + XCTAssertEqual(session.views[6].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[6])// go to "Screen2" - XCTAssertEqual(session.viewVisits[6].name, "Screen2") - XCTAssertEqual(session.viewVisits[6].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[6])// swipe back to "Screen1" + XCTAssertEqual(session.views[7].name, "Screen2") + XCTAssertEqual(session.views[7].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[7])// swipe back to "Screen1" - XCTAssertEqual(session.viewVisits[7].name, "Screen1") - XCTAssertEqual(session.viewVisits[7].path, "UIViewController") + XCTAssertEqual(session.views[8].name, "Screen1") + XCTAssertEqual(session.views[8].path, "UIViewController") } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMResourcesScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMResourcesScenarioTests.swift index aba62bf078..e2f41cc72d 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMResourcesScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMResourcesScenarioTests.swift @@ -27,13 +27,13 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .composition, + instrumentationMethod: .legacyComposition, initializationMethod: .afterSDK ) ) } - func testRUMURLSessionResourcesScenario_directWithAdditionalFirstyPartyHosts() throws { + func testRUMURLSessionResourcesScenario_legacyWithAdditionalFirstyPartyHosts() throws { try runTest( for: "RUMURLSessionResourcesScenario", expectations: Expectations( @@ -41,13 +41,13 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .directWithAdditionalFirstyPartyHosts, + instrumentationMethod: .legacyWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) } - func testRUMURLSessionResourcesScenario_directWithGlobalFirstPartyHosts() throws { + func testRUMURLSessionResourcesScenario_legacyWithFeatureFirstPartyHosts() throws { try runTest( for: "RUMURLSessionResourcesScenario", expectations: Expectations( @@ -55,7 +55,7 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .directWithGlobalFirstPartyHosts, + instrumentationMethod: .legacyWithFeatureFirstPartyHosts, initializationMethod: .afterSDK ) ) @@ -69,7 +69,35 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .inheritance, + instrumentationMethod: .legacyInheritance, + initializationMethod: .afterSDK + ) + ) + } + + func testRUMURLSessionResourcesScenario_delegateUsingFeatureFirstPartyHosts() throws { + try runTest( + for: "RUMURLSessionResourcesScenario", + expectations: Expectations( + expectedFirstPartyRequestsViewControllerName: "Runner.SendFirstPartyRequestsViewController", + expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" + ), + urlSessionSetup: .init( + instrumentationMethod: .delegateUsingFeatureFirstPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testRUMURLSessionResourcesScenario_delegateWithAdditionalFirstyPartyHosts() throws { + try runTest( + for: "RUMURLSessionResourcesScenario", + expectations: Expectations( + expectedFirstPartyRequestsViewControllerName: "Runner.SendFirstPartyRequestsViewController", + expectedThirdPartyRequestsViewControllerName: "Runner.SendThirdPartyRequestsViewController" + ), + urlSessionSetup: .init( + instrumentationMethod: .delegateWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) @@ -83,13 +111,13 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .composition, + instrumentationMethod: .legacyComposition, initializationMethod: .afterSDK ) ) } - func testRUMNSURLSessionResourcesScenario_directWithAdditionalFirstyPartyHosts() throws { + func testRUMNSURLSessionResourcesScenario_legacyWithAdditionalFirstyPartyHosts() throws { try runTest( for: "RUMNSURLSessionResourcesScenario", expectations: Expectations( @@ -97,13 +125,13 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .directWithAdditionalFirstyPartyHosts, + instrumentationMethod: .legacyWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) } - func testRUMNSURLSessionResourcesScenario_directWithGlobalFirstPartyHosts() throws { + func testRUMNSURLSessionResourcesScenario_legacyWithFeatureFirstPartyHosts() throws { try runTest( for: "RUMNSURLSessionResourcesScenario", expectations: Expectations( @@ -111,7 +139,7 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .directWithGlobalFirstPartyHosts, + instrumentationMethod: .legacyWithFeatureFirstPartyHosts, initializationMethod: .afterSDK ) ) @@ -125,7 +153,35 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" ), urlSessionSetup: .init( - instrumentationMethod: .inheritance, + instrumentationMethod: .legacyInheritance, + initializationMethod: .afterSDK + ) + ) + } + + func testRUMNSURLSessionResourcesScenario_delegateUsingFeatureFirstPartyHosts() throws { + try runTest( + for: "RUMNSURLSessionResourcesScenario", + expectations: Expectations( + expectedFirstPartyRequestsViewControllerName: "ObjcSendFirstPartyRequestsViewController", + expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" + ), + urlSessionSetup: .init( + instrumentationMethod: .delegateUsingFeatureFirstPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testRUMNSURLSessionResourcesScenario_delegateWithAdditionalFirstyPartyHosts() throws { + try runTest( + for: "RUMNSURLSessionResourcesScenario", + expectations: Expectations( + expectedFirstPartyRequestsViewControllerName: "ObjcSendFirstPartyRequestsViewController", + expectedThirdPartyRequestsViewControllerName: "ObjcSendThirdPartyRequestsViewController" + ), + urlSessionSetup: .init( + instrumentationMethod: .delegateWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) @@ -152,7 +208,7 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { // Requesting this first party by the app should create the RUM Resource and inject tracing headers into the request. let firstPartyPOSTResourceURL = customFirstPartyServerSession.recordingURL // Requesting this first party by the app should create the RUM Error. - let firstPartyBadResourceURL = URL(string: "https://foo.bar")! + let firstPartyBadResourceURL = URL(string: "https://foo.bar/")! // Requesting this third party by the app should create the RUM Resource. let thirdPartyGETResourceURL = URL(string: "https://shopist.io/categories.json")! @@ -216,14 +272,18 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(try RUMSessionMatcher.singleSession(from: rumRequests)) sendCIAppLog(session) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + // Asserts in `SendFirstPartyRequestsVC` RUM View - XCTAssertEqual(session.viewVisits[0].name, expectations.expectedFirstPartyRequestsViewControllerName) - XCTAssertEqual(session.viewVisits[0].path, expectations.expectedFirstPartyRequestsViewControllerName) - XCTAssertEqual(session.viewVisits[0].resourceEvents.count, 2, "1st screen should track 2 RUM Resources") - XCTAssertEqual(session.viewVisits[0].errorEvents.count, 1, "1st screen should track 1 RUM Errors") + XCTAssertEqual(session.views[1].name, expectations.expectedFirstPartyRequestsViewControllerName) + XCTAssertEqual(session.views[1].path, expectations.expectedFirstPartyRequestsViewControllerName) + XCTAssertEqual(session.views[1].resourceEvents.count, 2, "1st screen should track 2 RUM Resources") + XCTAssertEqual(session.views[1].errorEvents.count, 1, "1st screen should track 1 RUM Errors") let firstPartyResource1 = try XCTUnwrap( - session.viewVisits[0].resourceEvents.first { $0.resource.url == firstPartyGETResourceURL.absoluteString }, + session.views[1].resourceEvents.first { $0.resource.url == firstPartyGETResourceURL.absoluteString }, "RUM Resource should be send for `firstPartyGETResourceURL`" ) XCTAssertEqual(firstPartyResource1.resource.method, .get) @@ -235,7 +295,7 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertNotNil(firstPartyResource1.dd.rulePsr) let firstPartyResource2 = try XCTUnwrap( - session.viewVisits[0].resourceEvents.first { $0.resource.url == firstPartyPOSTResourceURL.absoluteString }, + session.views[1].resourceEvents.first { $0.resource.url == firstPartyPOSTResourceURL.absoluteString }, "RUM Resource should be send for `firstPartyPOSTResourceURL`" ) XCTAssertEqual(firstPartyResource2.resource.method, .post) @@ -255,19 +315,19 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertTrue(isValid(sampleRate: firstPartyResource2SampleRate), "\(firstPartyResource2SampleRate) is not valid sample rate") let firstPartyResourceError1 = try XCTUnwrap( - session.viewVisits[0].errorEvents.first { $0.error.resource?.url == firstPartyBadResourceURL.absoluteString }, + session.views[1].errorEvents.first { $0.error.resource?.url == firstPartyBadResourceURL.absoluteString }, "RUM Error should be send for `firstPartyBadResourceURL`" ) XCTAssertEqual(firstPartyResourceError1.error.resource?.method, .get) // Asserts in `SendThirdPartyRequestsVC` RUM View - XCTAssertEqual(session.viewVisits[1].name, expectations.expectedThirdPartyRequestsViewControllerName) - XCTAssertEqual(session.viewVisits[1].path, expectations.expectedThirdPartyRequestsViewControllerName) - XCTAssertEqual(session.viewVisits[1].resourceEvents.count, 2, "2nd screen should track 2 RUM Resources") - XCTAssertEqual(session.viewVisits[1].errorEvents.count, 0, "2nd screen should track no RUM Errors") + XCTAssertEqual(session.views[2].name, expectations.expectedThirdPartyRequestsViewControllerName) + XCTAssertEqual(session.views[2].path, expectations.expectedThirdPartyRequestsViewControllerName) + XCTAssertEqual(session.views[2].resourceEvents.count, 2, "2nd screen should track 2 RUM Resources") + XCTAssertEqual(session.views[2].errorEvents.count, 0, "2nd screen should track no RUM Errors") let thirdPartyResource1 = try XCTUnwrap( - session.viewVisits[1].resourceEvents.first { $0.resource.url == thirdPartyGETResourceURL.absoluteString }, + session.views[2].resourceEvents.first { $0.resource.url == thirdPartyGETResourceURL.absoluteString }, "RUM Resource should be send for `thirdPartyGETResourceURL`" ) XCTAssertEqual(thirdPartyResource1.resource.method, .get) @@ -278,7 +338,7 @@ class RUMResourcesScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertNil(thirdPartyResource1.dd.rulePsr, "Not traced resource should not send sample rate") let thirdPartyResource2 = try XCTUnwrap( - session.viewVisits[1].resourceEvents.first { $0.resource.url == thirdPartyPOSTResourceURL.absoluteString }, + session.views[2].resourceEvents.first { $0.resource.url == thirdPartyPOSTResourceURL.absoluteString }, "RUM Resource should be send for `thirdPartyPOSTResourceURL`" ) XCTAssertEqual(thirdPartyResource2.resource.method, .post) diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMScrubbingScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMScrubbingScenarioTests.swift index b1825578e9..606becb98b 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMScrubbingScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMScrubbingScenarioTests.swift @@ -31,16 +31,19 @@ class RUMScrubbingScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let viewVisit = session.viewVisits[0] + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(viewVisit.viewEvents.count, 0) - viewVisit.viewEvents.forEach { event in + let view = session.views[1] + XCTAssertGreaterThan(view.viewEvents.count, 0) + view.viewEvents.forEach { event in XCTAssertTrue(event.view.url.isRedacted) XCTAssertTrue(event.view.name?.isRedacted == true) } - XCTAssertGreaterThan(viewVisit.errorEvents.count, 0) - viewVisit.errorEvents.forEach { event in + XCTAssertGreaterThan(view.errorEvents.count, 0) + view.errorEvents.forEach { event in XCTAssertTrue(event.error.message.isRedacted) XCTAssertTrue(event.view.url.isRedacted) XCTAssertTrue(event.view.name?.isRedacted == true) @@ -48,14 +51,14 @@ class RUMScrubbingScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertTrue(event.error.stack?.isRedacted ?? true) } - XCTAssertGreaterThan(viewVisit.resourceEvents.count, 0) - viewVisit.resourceEvents.forEach { event in + XCTAssertGreaterThan(view.resourceEvents.count, 0) + view.resourceEvents.forEach { event in XCTAssertTrue(event.resource.url.isRedacted) XCTAssertTrue(event.view.name?.isRedacted == true) } - XCTAssertGreaterThan(viewVisit.actionEvents.count, 0) - viewVisit.actionEvents.forEach { event in + XCTAssertGreaterThan(view.actionEvents.count, 0) + view.actionEvents.forEach { event in XCTAssertTrue(event.action.target?.name.isRedacted ?? true) XCTAssertTrue(event.view.name?.isRedacted == true) } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMStopSessionScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMStopSessionScenarioTests.swift index ee193bb1bd..9d294af6d2 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMStopSessionScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMStopSessionScenarioTests.swift @@ -68,7 +68,7 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { let sessions = try RUMSessionMatcher.sessions(maxCount: 4, from: requests) // No active views in any session return sessions.count == 4 && sessions.allSatisfy { session in - !session.viewVisits.contains(where: { $0.viewEvents.last?.view.isActive == true }) + !session.views.contains(where: { $0.viewEvents.last?.view.isActive == true }) } } @@ -78,10 +78,12 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { do { let appStartSession = sessions[0] - let launchView = try XCTUnwrap(appStartSession.applicationLaunchView) - XCTAssertEqual(launchView.actionEvents[0].action.type, .applicationStart) + let initialView = appStartSession.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + XCTAssertTrue(initialView.viewEvents.allSatisfy { $0.dd.session?.sessionPrecondition == .userAppLaunch }) - let view1 = appStartSession.viewVisits[0] + let view1 = appStartSession.views[1] XCTAssertEqual(view1.name, "KioskViewController") XCTAssertEqual(view1.path, "Runner.KioskViewController") XCTAssertEqual(view1.viewEvents.last?.session.isActive, false) @@ -91,9 +93,8 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { // Second session sends a resource and ends returning to the KioskViewController do { let normalSession = sessions[1] - XCTAssertNil(normalSession.applicationLaunchView) - - let view1 = normalSession.viewVisits[0] + let view1 = normalSession.views[0] + XCTAssertTrue(view1.viewEvents.allSatisfy { $0.dd.session?.sessionPrecondition == .explicitStop }) XCTAssertTrue(try XCTUnwrap(view1.viewEvents.first?.session.isActive)) XCTAssertEqual(view1.name, "KioskSendEvents") XCTAssertEqual(view1.path, "Runner.KioskSendEventsViewController") @@ -105,7 +106,7 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertLessThan(view1.resourceEvents[0].resource.duration!, 1_000_000_000 * 30) // less than 30s (big enough to balance NTP sync) RUMSessionMatcher.assertViewWasEventuallyInactive(view1) - let view2 = normalSession.viewVisits[1] + let view2 = normalSession.views[1] XCTAssertEqual(view2.name, "KioskViewController") XCTAssertEqual(view2.path, "Runner.KioskViewController") XCTAssertEqual(view2.viewEvents.last?.session.isActive, false) @@ -115,9 +116,8 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { // Third session, same as the first but longer before completing resources do { let interruptedSession = sessions[2] - XCTAssertNil(interruptedSession.applicationLaunchView) - - let view1 = interruptedSession.viewVisits[0] + let view1 = interruptedSession.views[0] + XCTAssertTrue(view1.viewEvents.allSatisfy { $0.dd.session?.sessionPrecondition == .explicitStop }) XCTAssertEqual(view1.name, "KioskSendInterruptedEvents") XCTAssertEqual(view1.path, "Runner.KioskSendInterruptedEventsViewController") XCTAssertEqual(view1.resourceEvents[0].resource.url, "https://foo.com/resource/1") @@ -128,7 +128,7 @@ class RUMStopSessionScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertLessThan(view1.resourceEvents[0].resource.duration!, 1_000_000_000 * 30) // less than 30s (big enough to balance NTP sync) RUMSessionMatcher.assertViewWasEventuallyInactive(view1) - let view2 = interruptedSession.viewVisits[1] + let view2 = interruptedSession.views[1] XCTAssertEqual(view2.name, "KioskViewController") XCTAssertEqual(view2.path, "Runner.KioskViewController") XCTAssertEqual(view2.viewEvents.last?.session.isActive, false) diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMSwiftUIScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMSwiftUIScenarioTests.swift index bda2a9cfc9..cacd967452 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMSwiftUIScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMSwiftUIScenarioTests.swift @@ -75,48 +75,47 @@ class RUMSwiftUIScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: requests)) sendCIAppLog(session) - let applicationLaunchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(applicationLaunchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(applicationLaunchView.actionEvents[0].action.loadingTime!, 0) - - let visits = session.viewVisits - XCTAssertEqual(visits[0].name, "SwiftUI View 1") - XCTAssertTrue(visits[0].path.matches(regex: "SwiftUI View 1\\/[0-9]*")) - XCTAssertEqual(visits[0].actionEvents[0].action.target?.name, "Tap Push to Next View") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[0]) // go to "Screen 2" - - XCTAssertEqual(visits[1].name, "SwiftUI View 2") - XCTAssertTrue(visits[1].path.matches(regex: "SwiftUI View 2\\/[0-9]*")) - XCTAssertEqual(visits[1].actionEvents[0].action.target?.name, "Tap Push to Next View") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[1])// go to "Screen 3" - - XCTAssertEqual(visits[2].name, "SwiftUI View 3") - XCTAssertTrue(visits[2].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) - XCTAssertEqual(visits[2].actionEvents[0].action.target?.name, "Tap Modal View") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[2])// go to "Screen 4" - - XCTAssertEqual(visits[3].name, "UIKit View 4") - XCTAssertEqual(visits[3].path, "Runner.UIScreenViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[3])// go to "Screen 3" - - XCTAssertEqual(visits[4].name, "SwiftUI View 3") - XCTAssertTrue(visits[4].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[4])// go to "Screen 100" - - XCTAssertEqual(visits[5].name, "SwiftUI View 100") - XCTAssertTrue(visits[5].path.matches(regex: "SwiftUI View 100\\/[0-9]*")) - XCTAssertEqual(visits[5].actionEvents[0].action.target?.name, "Tap Modal View") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[5])// go to "Screen 101" - - XCTAssertEqual(visits[6].name, "SwiftUI View 101") - XCTAssertTrue(visits[6].path.matches(regex: "SwiftUI View 101\\/[0-9]*")) - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[6])// go to "Screen 100" - - XCTAssertEqual(visits[7].name, "SwiftUI View 100") - XCTAssertTrue(visits[7].path.matches(regex: "SwiftUI View 100\\/[0-9]*")) - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[7])// go to "Screen 3" - - XCTAssertEqual(visits[8].name, "SwiftUI View 3") - XCTAssertTrue(visits[8].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + XCTAssertEqual(session.views[1].name, "SwiftUI View 1") + XCTAssertTrue(session.views[1].path.matches(regex: "SwiftUI View 1\\/[0-9]*")) + XCTAssertEqual(session.views[1].actionEvents[0].action.target?.name, "Tap Push to Next View") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[1]) // go to "Screen 2" + + XCTAssertEqual(session.views[2].name, "SwiftUI View 2") + XCTAssertTrue(session.views[2].path.matches(regex: "SwiftUI View 2\\/[0-9]*")) + XCTAssertEqual(session.views[2].actionEvents[0].action.target?.name, "Tap Push to Next View") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[2])// go to "Screen 3" + + XCTAssertEqual(session.views[3].name, "SwiftUI View 3") + XCTAssertTrue(session.views[3].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) + XCTAssertEqual(session.views[3].actionEvents[0].action.target?.name, "Tap Modal View") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[3])// go to "Screen 4" + + XCTAssertEqual(session.views[4].name, "UIKit View 4") + XCTAssertEqual(session.views[4].path, "Runner.UIScreenViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[4])// go to "Screen 3" + + XCTAssertEqual(session.views[5].name, "SwiftUI View 3") + XCTAssertTrue(session.views[5].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[5])// go to "Screen 100" + + XCTAssertEqual(session.views[6].name, "SwiftUI View 100") + XCTAssertTrue(session.views[6].path.matches(regex: "SwiftUI View 100\\/[0-9]*")) + XCTAssertEqual(session.views[6].actionEvents[0].action.target?.name, "Tap Modal View") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[6])// go to "Screen 101" + + XCTAssertEqual(session.views[7].name, "SwiftUI View 101") + XCTAssertTrue(session.views[7].path.matches(regex: "SwiftUI View 101\\/[0-9]*")) + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[7])// go to "Screen 100" + + XCTAssertEqual(session.views[8].name, "SwiftUI View 100") + XCTAssertTrue(session.views[8].path.matches(regex: "SwiftUI View 100\\/[0-9]*")) + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[8])// go to "Screen 3" + + XCTAssertEqual(session.views[9].name, "SwiftUI View 3") + XCTAssertTrue(session.views[9].path.matches(regex: "SwiftUI View 3\\/[0-9]*")) } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTabBarControllerScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTabBarControllerScenarioTests.swift index ba70cf0b28..062468164f 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTabBarControllerScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTabBarControllerScenarioTests.swift @@ -55,44 +55,43 @@ class RUMTabBarControllerScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let applicationLaunchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(applicationLaunchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(applicationLaunchView.actionEvents[0].action.loadingTime!, 0) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) - let visits = session.viewVisits - XCTAssertEqual(session.viewVisits[0].name, "Screen A") - XCTAssertEqual(session.viewVisits[0].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[0]) // go to "Screen B1" + XCTAssertEqual(session.views[1].name, "Screen A") + XCTAssertEqual(session.views[1].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[1]) // go to "Screen B1" - XCTAssertEqual(session.viewVisits[1].name, "Screen B1") - XCTAssertEqual(session.viewVisits[1].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[1])// go to "Screen B2" + XCTAssertEqual(session.views[2].name, "Screen B1") + XCTAssertEqual(session.views[2].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[2])// go to "Screen B2" - XCTAssertEqual(session.viewVisits[2].name, "Screen B2") - XCTAssertEqual(session.viewVisits[2].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[2])// go to "Screen B1" + XCTAssertEqual(session.views[3].name, "Screen B2") + XCTAssertEqual(session.views[3].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[3])// go to "Screen B1" - XCTAssertEqual(session.viewVisits[3].name, "Screen B1") - XCTAssertEqual(session.viewVisits[3].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[3])// go to "Screen C1" + XCTAssertEqual(session.views[4].name, "Screen B1") + XCTAssertEqual(session.views[4].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[4])// go to "Screen C1" - XCTAssertEqual(session.viewVisits[4].name, "Screen C1") - XCTAssertEqual(session.viewVisits[4].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[4])// go to "Screen C2" + XCTAssertEqual(session.views[5].name, "Screen C1") + XCTAssertEqual(session.views[5].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[5])// go to "Screen C2" - XCTAssertEqual(session.viewVisits[5].name, "Screen C2") - XCTAssertEqual(session.viewVisits[5].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[5])// go to "Screen A" + XCTAssertEqual(session.views[6].name, "Screen C2") + XCTAssertEqual(session.views[6].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[6])// go to "Screen A" - XCTAssertEqual(session.viewVisits[6].name, "Screen A") - XCTAssertEqual(session.viewVisits[6].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[6])// go to "Screen C2" + XCTAssertEqual(session.views[7].name, "Screen A") + XCTAssertEqual(session.views[7].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[7])// go to "Screen C2" - XCTAssertEqual(session.viewVisits[7].name, "Screen C2") - XCTAssertEqual(session.viewVisits[7].path, "UIViewController") - RUMSessionMatcher.assertViewWasEventuallyInactive(visits[7])// go to "Screen C1" + XCTAssertEqual(session.views[8].name, "Screen C2") + XCTAssertEqual(session.views[8].path, "UIViewController") + RUMSessionMatcher.assertViewWasEventuallyInactive(session.views[8])// go to "Screen C1" - XCTAssertEqual(session.viewVisits[8].name, "Screen C1") - XCTAssertEqual(session.viewVisits[8].path, "UIViewController") + XCTAssertEqual(session.views[9].name, "Screen C1") + XCTAssertEqual(session.views[9].path, "UIViewController") } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTapActionScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTapActionScenarioTests.swift index 65dd50ae49..0c0b81eacd 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTapActionScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/RUM/RUMTapActionScenarioTests.swift @@ -118,46 +118,46 @@ class RUMTapActionScenarioTests: IntegrationTests, RUMCommonAsserts { let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) sendCIAppLog(session) - let applicationLaunchView = try XCTUnwrap(session.applicationLaunchView) - XCTAssertEqual(applicationLaunchView.actionEvents[0].action.type, .applicationStart) - XCTAssertGreaterThan(applicationLaunchView.actionEvents[0].action.loadingTime!, 0) - - XCTAssertEqual(session.viewVisits[0].name, "MenuView") - XCTAssertEqual(session.viewVisits[0].path, "Runner.RUMTASScreen1ViewController") - XCTAssertEqual(session.viewVisits[0].actionEvents.count, 2) - XCTAssertEqual(session.viewVisits[0].actionEvents[0].action.target?.name, "UIButton") - XCTAssertEqual(session.viewVisits[0].actionEvents[1].action.target?.name, "UIButton(Show UITableView)") - - XCTAssertEqual(session.viewVisits[1].name, "TableView") - XCTAssertEqual(session.viewVisits[1].path, "Runner.RUMTASTableViewController") - XCTAssertEqual(session.viewVisits[1].actionEvents.count, 1) + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + XCTAssertEqual(session.views[1].name, "MenuView") + XCTAssertEqual(session.views[1].path, "Runner.RUMTASScreen1ViewController") + XCTAssertEqual(session.views[1].actionEvents.count, 2) + XCTAssertEqual(session.views[1].actionEvents[0].action.target?.name, "UIButton") + XCTAssertEqual(session.views[1].actionEvents[1].action.target?.name, "UIButton(Show UITableView)") + + XCTAssertEqual(session.views[2].name, "TableView") + XCTAssertEqual(session.views[2].path, "Runner.RUMTASTableViewController") + XCTAssertEqual(session.views[2].actionEvents.count, 1) XCTAssertEqual( - session.viewVisits[1].actionEvents[0].action.target?.name, + session.views[2].actionEvents[0].action.target?.name, "UITableViewCell(Item 4)" ) - XCTAssertEqual(session.viewVisits[2].name, "MenuView") - XCTAssertEqual(session.viewVisits[2].path, "Runner.RUMTASScreen1ViewController") - XCTAssertEqual(session.viewVisits[2].actionEvents.count, 1) - XCTAssertEqual(session.viewVisits[2].actionEvents[0].action.target?.name, "UIButton(Show UICollectionView)") + XCTAssertEqual(session.views[3].name, "MenuView") + XCTAssertEqual(session.views[3].path, "Runner.RUMTASScreen1ViewController") + XCTAssertEqual(session.views[3].actionEvents.count, 1) + XCTAssertEqual(session.views[3].actionEvents[0].action.target?.name, "UIButton(Show UICollectionView)") - XCTAssertEqual(session.viewVisits[3].name, "CollectionView") - XCTAssertEqual(session.viewVisits[3].path, "Runner.RUMTASCollectionViewController") - XCTAssertEqual(session.viewVisits[3].actionEvents.count, 1) + XCTAssertEqual(session.views[4].name, "CollectionView") + XCTAssertEqual(session.views[4].path, "Runner.RUMTASCollectionViewController") + XCTAssertEqual(session.views[4].actionEvents.count, 1) XCTAssertEqual( - session.viewVisits[3].actionEvents[0].action.target?.name, + session.views[4].actionEvents[0].action.target?.name, "Runner.RUMTASCollectionViewCell(Item 14)" ) - XCTAssertEqual(session.viewVisits[4].name, "MenuView") - XCTAssertEqual(session.viewVisits[4].path, "Runner.RUMTASScreen1ViewController") - XCTAssertEqual(session.viewVisits[4].actionEvents.count, 1) - XCTAssertEqual(session.viewVisits[4].actionEvents[0].action.target?.name, "UIButton(Show various UIControls)") + XCTAssertEqual(session.views[5].name, "MenuView") + XCTAssertEqual(session.views[5].path, "Runner.RUMTASScreen1ViewController") + XCTAssertEqual(session.views[5].actionEvents.count, 1) + XCTAssertEqual(session.views[5].actionEvents[0].action.target?.name, "UIButton(Show various UIControls)") - XCTAssertEqual(session.viewVisits[5].name, "UIControlsView") - XCTAssertEqual(session.viewVisits[5].path, "Runner.RUMTASVariousUIControllsViewController") - XCTAssertEqual(session.viewVisits[5].actionEvents.count, 7) - let targetNames = session.viewVisits[5].actionEvents.compactMap { $0.action.target?.name } + XCTAssertEqual(session.views[6].name, "UIControlsView") + XCTAssertEqual(session.views[6].path, "Runner.RUMTASVariousUIControllsViewController") + XCTAssertEqual(session.views[6].actionEvents.count, 7) + let targetNames = session.views[6].actionEvents.compactMap { $0.action.target?.name } XCTAssertEqual(targetNames[0], "UITextField") XCTAssertEqual(targetNames[1], "UIStepper") XCTAssertEqual(targetNames[2], "UISlider") @@ -166,8 +166,8 @@ class RUMTapActionScenarioTests: IntegrationTests, RUMCommonAsserts { XCTAssertEqual(targetNames[5], "_UIButtonBarButton(Share)") XCTAssert(targetNames[6].contains("_UIButtonBarButton"), "Target name should be either _UIButtonBarButton (iOS 13) or _UIButtonBarButton(BackButton) (iOS 14)") // back button - XCTAssertEqual(session.viewVisits[6].name, "MenuView") - XCTAssertEqual(session.viewVisits[6].path, "Runner.RUMTASScreen1ViewController") - XCTAssertEqual(session.viewVisits[6].actionEvents.count, 0) + XCTAssertEqual(session.views[7].name, "MenuView") + XCTAssertEqual(session.views[7].path, "Runner.RUMTASScreen1ViewController") + XCTAssertEqual(session.views[7].actionEvents.count, 0) } } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift index b0dbce33f5..eb7fc03298 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift @@ -85,7 +85,7 @@ class SRMultipleViewsRecordingScenarioTests: IntegrationTests, RUMCommonAsserts, // Read SR segments from SR requests (one request = one segment): let segments = try srRequests.map { try SRSegmentMatcher.fromJSONData($0.segmentJSONData()) } - XCTAssertFalse(rumSession.viewVisits.isEmpty, "There should be some RUM session") + XCTAssertFalse(rumSession.views.isEmpty, "There should be some RUM session") XCTAssertFalse(srRequests.isEmpty, "There should be some SR requests") XCTAssertFalse(segments.isEmpty, "There should be some SR segments") sendCIAppLog(rumSession) diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/Tracing/TracingURLSessionScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/Tracing/TracingURLSessionScenarioTests.swift index ab791b66ef..2e533a44da 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/Tracing/TracingURLSessionScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/Tracing/TracingURLSessionScenarioTests.swift @@ -18,17 +18,17 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { try runTest( for: "TracingURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .composition, + instrumentationMethod: .legacyComposition, initializationMethod: .afterSDK ) ) } - func testTracingURLSessionScenario_directWithAdditionalFirstyPartyHosts() throws { + func testTracingURLSessionScenario_legacyWithAdditionalFirstyPartyHosts() throws { try runTest( for: "TracingURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .directWithAdditionalFirstyPartyHosts, + instrumentationMethod: .legacyWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) @@ -38,7 +38,27 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { try runTest( for: "TracingURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .directWithGlobalFirstPartyHosts, + instrumentationMethod: .legacyWithFeatureFirstPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testTracingURLSessionScenario_delegateUsingFeatureFirstPartyHosts() throws { + try runTest( + for: "TracingURLSessionScenario", + urlSessionSetup: .init( + instrumentationMethod: .delegateUsingFeatureFirstPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testTracingURLSessionScenario_delegateWithAdditionalFirstyPartyHosts() throws { + try runTest( + for: "TracingURLSessionScenario", + urlSessionSetup: .init( + instrumentationMethod: .delegateWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) @@ -48,7 +68,7 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { try runTest( for: "TracingURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .inheritance, + instrumentationMethod: .legacyInheritance, initializationMethod: .afterSDK ) ) @@ -58,17 +78,47 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { try runTest( for: "TracingNSURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .composition, + instrumentationMethod: .legacyComposition, + initializationMethod: .afterSDK + ) + ) + } + + func testTracingNSURLSessionScenario_legacyWithFeatureFirstPartyHosts() throws { + try runTest( + for: "TracingNSURLSessionScenario", + urlSessionSetup: .init( + instrumentationMethod: .legacyWithFeatureFirstPartyHosts, initializationMethod: .afterSDK ) ) } - func testTracingNSURLSessionScenario_directWithAdditionalFirstyPartyHosts() throws { + func testTracingNSURLSessionScenario_legacyWithAdditionalFirstyPartyHosts() throws { + try runTest( + for: "TracingNSURLSessionScenario", + urlSessionSetup: .init( + instrumentationMethod: .legacyWithAdditionalFirstyPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testTracingNSURLSessionScenario_delegateUsingFeatureFirstPartyHosts() throws { + try runTest( + for: "TracingNSURLSessionScenario", + urlSessionSetup: .init( + instrumentationMethod: .delegateUsingFeatureFirstPartyHosts, + initializationMethod: .afterSDK + ) + ) + } + + func testTracingNSURLSessionScenario_delegateWithAdditionalFirstyPartyHosts() throws { try runTest( for: "TracingNSURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .directWithAdditionalFirstyPartyHosts, + instrumentationMethod: .delegateWithAdditionalFirstyPartyHosts, initializationMethod: .afterSDK ) ) @@ -78,7 +128,7 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { try runTest( for: "TracingNSURLSessionScenario", urlSessionSetup: .init( - instrumentationMethod: .inheritance, + instrumentationMethod: .legacyInheritance, initializationMethod: .afterSDK ) ) @@ -103,7 +153,7 @@ class TracingURLSessionScenarioTests: IntegrationTests, TracingCommonAsserts { // Requesting this first party by the app should create the `SpanEvent`. let firstPartyPOSTResourceURL = customFirstPartyServerSession.recordingURL // Requesting this first party by the app should create the `SpanEvent` with error. - let firstPartyBadResourceURL = URL(string: "https://foo.bar")! + let firstPartyBadResourceURL = URL(string: "https://foo.bar/")! // Requesting this third party by the app should NOT create the `SpanEvent`. let thirdPartyGETResourceURL = URL(string: "https://bitrise.io")! diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/TrackingConsent/TrackingConsentScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/TrackingConsent/TrackingConsentScenarioTests.swift index cc75ae4039..d5574b0d2a 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/TrackingConsent/TrackingConsentScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/TrackingConsent/TrackingConsentScenarioTests.swift @@ -192,18 +192,24 @@ class TrackingConsentScenarioTests: IntegrationTests, LoggingCommonAsserts, Trac // from this session to be send, but no RUM, Logging nor Tracing events from the first // session should be recorded. let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in - try RUMSessionMatcher.singleSession(from: requests)?.viewVisits.count == 1 + try RUMSessionMatcher.singleSession(from: requests)?.views.count == 2 } assertRUM(requests: recordedRUMRequests) let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests)) - XCTAssertEqual(session.viewVisits.count, 1) - XCTAssertEqual(session.viewVisits[0].path, "Runner.TSHomeViewController") + sendCIAppLog(session) + + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + XCTAssertEqual(session.views[1].path, "Runner.TSHomeViewController") try recordedRUMRequests .flatMap { request in try RUMEventMatcher.fromNewlineSeparatedJSONObjectsData(request.httpBody) } - .filter { event in try event.eventType() != "telemetry" } + .filterTelemetry() + .filterApplicationLaunchView() .forEach { event in XCTAssertEqual( try event.attribute(forKeyPath: "usr.current-consent-value"), @@ -285,28 +291,34 @@ class TrackingConsentScenarioTests: IntegrationTests, LoggingCommonAsserts, Trac andSentTo serverSession: ServerSession ) throws { let recordedRequests = try serverSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in - try RUMSessionMatcher.singleSession(from: requests)?.viewVisits.count == 4 + try RUMSessionMatcher.singleSession(from: requests)?.views.count == 5 } assertRUM(requests: recordedRequests) let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRequests)) - XCTAssertEqual(session.viewVisits[0].path, "Runner.TSHomeViewController") - XCTAssertGreaterThan(session.viewVisits[0].actionEvents.count, 0) + sendCIAppLog(session) + + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + XCTAssertEqual(session.views[1].path, "Runner.TSHomeViewController") + XCTAssertGreaterThan(session.views[1].actionEvents.count, 0) - XCTAssertEqual(session.viewVisits[1].path, "Runner.TSPictureViewController") - XCTAssertEqual(session.viewVisits[1].resourceEvents.count, 1) - XCTAssertGreaterThan(session.viewVisits[1].actionEvents.count, 0) + XCTAssertEqual(session.views[2].path, "Runner.TSPictureViewController") + XCTAssertEqual(session.views[2].resourceEvents.count, 1) + XCTAssertGreaterThan(session.views[2].actionEvents.count, 0) - XCTAssertEqual(session.viewVisits[2].path, "Runner.TSHomeViewController") - XCTAssertGreaterThan(session.viewVisits[0].actionEvents.count, 0) + XCTAssertEqual(session.views[3].path, "Runner.TSHomeViewController") + XCTAssertGreaterThan(session.views[3].actionEvents.count, 0) - XCTAssertEqual(session.viewVisits[3].path, "Runner.TSConsentSettingViewController") - XCTAssertGreaterThan(session.viewVisits[0].actionEvents.count, 0) + XCTAssertEqual(session.views[4].path, "Runner.TSConsentSettingViewController") let eventMatchers = try recordedRequests .flatMap { request in try RUMEventMatcher.fromNewlineSeparatedJSONObjectsData(request.httpBody) } - .filter { event in try event.eventType() != "telemetry" } + .filterTelemetry() + .filterApplicationLaunchView() try eventMatchers.forEach { event in XCTAssertEqual( diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/WebView/WebViewScenarioTest.swift b/IntegrationTests/IntegrationScenarios/Scenarios/WebView/WebViewScenarioTest.swift index 32f4648e9e..860ca48ef8 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/WebView/WebViewScenarioTest.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/WebView/WebViewScenarioTest.swift @@ -28,15 +28,21 @@ class WebViewScenarioTest: IntegrationTests, RUMCommonAsserts { // Get single RUM Session with expected number of View visits let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in - try RUMSessionMatcher.singleSession(from: requests, eventsPatch: patchBrowserEvents)?.viewVisits.count == 2 + try RUMSessionMatcher.singleSession(from: requests, eventsPatch: patchBrowserEvents)?.views.count == 3 } assertRUM(requests: recordedRUMRequests) let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests, eventsPatch: patchBrowserEvents)) - XCTAssertEqual(session.viewVisits.count, 2, "There should be 2 RUM views - one native and one received from Browser SDK") + sendCIAppLog(session) + + XCTAssertEqual(session.views.count, 3, "There should be 3 RUM views - two native and one received from Browser SDK") // Check iOS SDK events: - let nativeView = session.viewVisits[0] + let initialView = session.views[0] + XCTAssertTrue(initialView.isApplicationLaunchView(), "The session should start with 'application launch' view") + XCTAssertEqual(initialView.actionEvents[0].action.type, .applicationStart) + + let nativeView = session.views[1] XCTAssertEqual(nativeView.name, "Runner.WebViewTrackingFixtureViewController") XCTAssertEqual(nativeView.path, "Runner.WebViewTrackingFixtureViewController") @@ -50,7 +56,7 @@ class WebViewScenarioTest: IntegrationTests, RUMCommonAsserts { let expectedBrowserRUMApplicationID = nativeView.viewEvents[0].application.id let expectedBrowserSessionID = nativeView.viewEvents[0].session.id - let browserView = session.viewVisits[1] + let browserView = session.views[2] XCTAssertNil(browserView.name, "Browser views should have no `name`") XCTAssertEqual(browserView.path, "https://shopist.io/") diff --git a/IntegrationTests/Runner/Environment.swift b/IntegrationTests/Runner/Environment.swift index d6ca220603..ddac1ddca1 100644 --- a/IntegrationTests/Runner/Environment.swift +++ b/IntegrationTests/Runner/Environment.swift @@ -45,16 +45,22 @@ internal struct URLSessionSetup: Codable { enum InstrumentationMethod: CaseIterable, Codable { /// Use `DDURLSessionDelegate` class directly. /// and define `firstPartyHosts` in feature configuration. - case directWithGlobalFirstPartyHosts + case legacyWithFeatureFirstPartyHosts /// Use `DDURLSessionDelegate` class directly - /// and define `firstPartyHosts` in `URLSession` delegate's configuration. - case directWithAdditionalFirstyPartyHosts + /// and define `firstPartyHosts` with delegate's configuration. + case legacyWithAdditionalFirstyPartyHosts /// Use `DDURLSessionDelegate` by inheriting it in a subclass (see: `InheritedURLSessionDelegate`). /// and define `firstPartyHosts` in feature configuration. - case inheritance + case legacyInheritance /// Use `DDURLSessionDelegate` by compositing it custom delegate class (see: `CompositedURLSessionDelegate`). /// and define `firstPartyHosts` in feature configuration. - case composition + case legacyComposition + /// Use a custom delegate. + /// and define `firstPartyHosts` in feature configuration. + case delegateUsingFeatureFirstPartyHosts + /// Use a custom delegate. + /// and define `firstPartyHosts` with delegate's configuration. + case delegateWithAdditionalFirstyPartyHosts } /// A method of instrumenting `URLSession` with `DDURLSessionDelegate`. diff --git a/IntegrationTests/Runner/Scenarios/RUM/RUMScenarios.swift b/IntegrationTests/Runner/Scenarios/RUM/RUMScenarios.swift index 57a14cf67f..d49d6944f0 100644 --- a/IntegrationTests/Runner/Scenarios/RUM/RUMScenarios.swift +++ b/IntegrationTests/Runner/Scenarios/RUM/RUMScenarios.swift @@ -181,7 +181,7 @@ class RUMResourcesBaseScenario: URLSessionBaseScenario { config.uiKitViewsPredicate = DefaultUIKitRUMViewsPredicate() switch setup.instrumentationMethod { - case .directWithGlobalFirstPartyHosts, .inheritance, .composition: + case .legacyWithFeatureFirstPartyHosts, .legacyInheritance, .legacyComposition, .delegateUsingFeatureFirstPartyHosts: config.urlSessionTracking = .init( firstPartyHostsTracing: .trace( hosts: [ @@ -193,7 +193,7 @@ class RUMResourcesBaseScenario: URLSessionBaseScenario { ), resourceAttributesProvider: rumResourceAttributesProvider(request:response:data:error:) ) - case .directWithAdditionalFirstyPartyHosts: + case .legacyWithAdditionalFirstyPartyHosts, .delegateWithAdditionalFirstyPartyHosts: config.urlSessionTracking = .init( firstPartyHostsTracing: .trace(hosts: [], sampleRate: 100), // hosts will be set through `DDURLSessionDelegate` resourceAttributesProvider: rumResourceAttributesProvider(request:response:data:error:) @@ -207,16 +207,12 @@ class RUMResourcesBaseScenario: URLSessionBaseScenario { /// sent with Swift `URLSession` from two VCs. The first VC calls first party resources, the second one calls third parties. final class RUMURLSessionResourcesScenario: RUMResourcesBaseScenario, TestScenario { static let storyboardName = "URLSessionScenario" - - override func configureFeatures() { super.configureFeatures() } } /// Scenario which uses RUM resources instrumentation to track bunch of network requests /// sent with Objective-c `NSURLSession` from two VCs. The first VC calls first party resources, the second one calls third parties. final class RUMNSURLSessionResourcesScenario: RUMResourcesBaseScenario, TestScenario { static let storyboardName = "NSURLSessionScenario" - - override func configureFeatures() { super.configureFeatures() } } /// Scenario which uses RUM manual instrumentation API to send bunch of RUM events. Each event contains some diff --git a/IntegrationTests/Runner/Scenarios/Tracing/TracingScenarios.swift b/IntegrationTests/Runner/Scenarios/Tracing/TracingScenarios.swift index b082ac08a3..9dc5a24ae6 100644 --- a/IntegrationTests/Runner/Scenarios/Tracing/TracingScenarios.swift +++ b/IntegrationTests/Runner/Scenarios/Tracing/TracingScenarios.swift @@ -47,7 +47,7 @@ class TracingURLSessionBaseScenario: URLSessionBaseScenario { } switch setup.instrumentationMethod { - case .directWithGlobalFirstPartyHosts, .inheritance, .composition: + case .legacyWithFeatureFirstPartyHosts, .legacyInheritance, .legacyComposition, .delegateUsingFeatureFirstPartyHosts: config.urlSessionTracking = .init( firstPartyHostsTracing: .trace( hosts: [ @@ -58,7 +58,7 @@ class TracingURLSessionBaseScenario: URLSessionBaseScenario { sampleRate: 100 ) ) - case .directWithAdditionalFirstyPartyHosts: + case .legacyWithAdditionalFirstyPartyHosts, .delegateWithAdditionalFirstyPartyHosts: config.urlSessionTracking = .init( firstPartyHostsTracing: .trace(hosts: [], sampleRate: 100) // hosts will be set through `DDURLSessionDelegate` ) diff --git a/IntegrationTests/Runner/Scenarios/URLSession/URLSessionScenarios.swift b/IntegrationTests/Runner/Scenarios/URLSession/URLSessionScenarios.swift index c3af654fc1..637b7ffa6f 100644 --- a/IntegrationTests/Runner/Scenarios/URLSession/URLSessionScenarios.swift +++ b/IntegrationTests/Runner/Scenarios/URLSession/URLSessionScenarios.swift @@ -20,22 +20,30 @@ private class InheritedURLSessionDelegate: DDURLSessionDelegate { /// An example of instrumenting existing `URLSessionDelegate` with `DDURLSessionDelegate` through composition. private class CompositedURLSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate, __URLSessionDelegateProviding { // MARK: - __URLSessionDelegateProviding conformance + let ddURLSessionDelegate = DatadogURLSessionDelegate() // MARK: - __URLSessionDelegateProviding handling - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + ddURLSessionDelegate.urlSession(session, task: task, didFinishCollecting: metrics) // forward to DD /* run custom logic */ } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + ddURLSessionDelegate.urlSession(session, task: task, didCompleteWithError: error) // forward to DD /* run custom logic */ } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + ddURLSessionDelegate.urlSession(session, dataTask: dataTask, didReceive: data) // forward to DD /* run custom logic */ } } +/// An example of instrumenting existing `URLSessionDelegate` with `DDURLSessionDelegate` through inheritance. +private class CustomURLSessionDelegate: NSObject, URLSessionDataDelegate { + +} + /// Base scenario for `URLSession` and `NSURLSession` instrumentation. It makes /// both Swift and Objective-C tests share the same endpoints and SDK configuration. /// @@ -141,9 +149,9 @@ class URLSessionBaseScenario: NSObject { let delegate: URLSessionDataDelegate switch setup.instrumentationMethod { - case .directWithGlobalFirstPartyHosts: + case .legacyWithFeatureFirstPartyHosts: delegate = DDURLSessionDelegate() - case .directWithAdditionalFirstyPartyHosts: + case .legacyWithAdditionalFirstyPartyHosts: delegate = DDURLSessionDelegate( additionalFirstPartyHosts: [ customGETResourceURL.host!, @@ -151,11 +159,25 @@ class URLSessionBaseScenario: NSObject { badResourceURL.host! ] ) - case .inheritance: + case .legacyInheritance: delegate = InheritedURLSessionDelegate() - case .composition: + case .legacyComposition: delegate = CompositedURLSessionDelegate() - URLSessionInstrumentation.enable(with: .init(delegateClass: CompositedURLSessionDelegate.self)) + case .delegateUsingFeatureFirstPartyHosts: + URLSessionInstrumentation.enable(with: .init(delegateClass: CustomURLSessionDelegate.self)) + delegate = CustomURLSessionDelegate() + case .delegateWithAdditionalFirstyPartyHosts: + URLSessionInstrumentation.enable( + with: .init( + delegateClass: CustomURLSessionDelegate.self, + firstPartyHostsTracing: .trace(hosts: [ + customGETResourceURL.host!, + customPOSTRequest.url!.host!, + badResourceURL.host! + ]) + ) + ) + delegate = CustomURLSessionDelegate() } return URLSession( diff --git a/Makefile b/Makefile index 0fba714c25..1dedd57754 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,8 @@ dependencies: ifeq (${ci}, true) @echo $$DD_SDK_BASE_XCCONFIG_CI >> xcconfigs/Base.local.xcconfig; @echo $$DD_SDK_DATADOG_XCCONFIG_CI > xcconfigs/Datadog.local.xcconfig; - @echo $$DD_SDK_TESTING_XCCONFIG_CI > xcconfigs/DatadogSDKTesting.local.xcconfig; +ifndef DD_DISABLE_TEST_INSTRUMENTING + @echo $$DD_SDK_TESTING_XCCONFIG_CI > xcconfigs/DatadogSDKTesting.local.xcconfig; @rm -rf instrumented-tests/DatadogSDKTesting.xcframework @rm -rf instrumented-tests/DatadogSDKTesting.zip @rm -rf instrumented-tests/LICENSE @@ -68,6 +69,8 @@ ifeq (${ci}, true) @unzip -q instrumented-tests/DatadogSDKTesting.zip -d instrumented-tests @[ -e "instrumented-tests/DatadogSDKTesting.xcframework" ] && echo "DatadogSDKTesting.xcframework - OK" || { echo "DatadogSDKTesting.xcframework - missing"; exit 1; } endif + +endif xcodeproj-session-replay: @echo "⚙️ Generating 'DatadogSessionReplay.xcodeproj'..." diff --git a/TestUtilities.podspec b/TestUtilities.podspec index a2f2bf882b..cc22335c55 100644 --- a/TestUtilities.podspec +++ b/TestUtilities.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "TestUtilities" - s.version = "2.5.1" + s.version = "2.6.0" s.summary = "Datadog Testing Utilities. This module is for internal testing and should not be published." s.homepage = "https://www.datadoghq.com" diff --git a/TestUtilities/Helpers/DDAssert.swift b/TestUtilities/Helpers/DDAssert.swift index 8a9e6b5e01..213ef9b875 100644 --- a/TestUtilities/Helpers/DDAssert.swift +++ b/TestUtilities/Helpers/DDAssert.swift @@ -54,15 +54,15 @@ private func _DDAssertReflectionEqual(_ expression1: @autoclosure () throws -> A let mirror1 = Mirror(reflecting: value1) let mirror2 = Mirror(reflecting: value2) - guard mirror1.displayStyle == mirror1.displayStyle else { + guard mirror1.displayStyle == mirror2.displayStyle else { throw DDAssertError.expectedFailure("(\"\(value1)\") and (\"\(value2)\") have different types", keyPath: keyPath) } - guard mirror1.children.count == mirror1.children.count else { + guard mirror1.children.count == mirror2.children.count else { throw DDAssertError.expectedFailure("(\"\(value1)\") and (\"\(value2)\") have different number of children", keyPath: keyPath) } - if mirror1.children.isEmpty, mirror1.children.isEmpty { + if mirror1.children.isEmpty && mirror2.children.isEmpty { guard String(describing: value1) == String(describing: value2) else { // plain values, compare debug strings throw DDAssertError.expectedFailure("(\"\(value1)\") is not equal to (\"\(value2)\")", keyPath: keyPath) } diff --git a/TestUtilities/Mocks/DatadogContextMock.swift b/TestUtilities/Mocks/DatadogContextMock.swift index 809c43a6db..f70fd5925c 100644 --- a/TestUtilities/Mocks/DatadogContextMock.swift +++ b/TestUtilities/Mocks/DatadogContextMock.swift @@ -17,6 +17,7 @@ extension DatadogContext: AnyMockable { env: String = .mockAny(), version: String = .mockAny(), buildNumber: String = .mockAny(), + buildId: String? = nil, variant: String? = nil, source: String = .mockAny(), sdkVersion: String = .mockAny(), @@ -43,6 +44,7 @@ extension DatadogContext: AnyMockable { env: env, version: version, buildNumber: buildNumber, + buildId: buildId, variant: variant, source: source, sdkVersion: sdkVersion, @@ -72,6 +74,7 @@ extension DatadogContext: AnyMockable { env: .mockRandom(), version: .mockRandom(), buildNumber: .mockRandom(), + buildId: .mockRandom(), variant: .mockRandom(), source: .mockAnySource(), sdkVersion: .mockRandom(), @@ -110,12 +113,12 @@ extension DeviceInfo { } public static func mockWith( - name: String = .mockAny(), - model: String = .mockAny(), - osName: String = .mockAny(), - osVersion: String = .mockAny(), - osBuildNumber: String = .mockAny(), - architecture: String = .mockAny() + name: String = "iPhone", + model: String = "iPhone10,1", + osName: String = "iOS", + osVersion: String = "15.4.1", + osBuildNumber: String = "13D20", + architecture: String = "arm64e" ) -> DeviceInfo { return .init( name: name, @@ -166,6 +169,18 @@ extension LaunchTime: AnyMockable { isActivePrewarm: .mockAny() ) } + + public static func mockWith( + launchTime: TimeInterval? = 1, + launchDate: Date = Date(), + isActivePrewarm: Bool = false + ) -> LaunchTime { + .init( + launchTime: launchTime, + launchDate: launchDate, + isActivePrewarm: isActivePrewarm + ) + } } extension AppState: AnyMockable, RandomMockable { diff --git a/TestUtilities/Mocks/FoundationMocks.swift b/TestUtilities/Mocks/FoundationMocks.swift index 96446a41ef..c8e1e5017e 100644 --- a/TestUtilities/Mocks/FoundationMocks.swift +++ b/TestUtilities/Mocks/FoundationMocks.swift @@ -720,6 +720,7 @@ extension URLSessionTaskTransactionMetrics { private class URLSessionDataTaskMock: URLSessionDataTask { private let _originalRequest: URLRequest override var originalRequest: URLRequest? { _originalRequest } + override var currentRequest: URLRequest? { _originalRequest } private let _response: URLResponse override var response: URLResponse? { _response } diff --git a/bitrise.yml b/bitrise.yml index 874a5ead3e..df1f058905 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -197,6 +197,14 @@ workflows: #!/usr/bin/env zsh set -e make rum-models-verify ci=${CI} + - script: + title: Verify SR data models + run_if: '{{enveq "DD_RUN_SR_UNIT_TESTS" "1"}}' + inputs: + - content: |- + #!/usr/bin/env zsh + set -e + make sr-models-verify ci=${CI} - xcode-test: title: Run unit tests for Datadog - iOS Simulator run_if: '{{enveq "DD_RUN_UNIT_TESTS" "1"}}' diff --git a/tools/distribution/dogfood.py b/tools/distribution/dogfood.py index 861b35cd88..0b5bca1365 100755 --- a/tools/distribution/dogfood.py +++ b/tools/distribution/dogfood.py @@ -27,6 +27,7 @@ def dogfood(dry_run: bool, repository_url: str, repository_name: str, repository dd_sdk_package_path = '../..' os.system(f'swift package --package-path {dd_sdk_package_path} resolve') dd_sdk_ios_package = PackageResolvedFile(path=f'{dd_sdk_package_path}/Package.resolved') + dd_sdk_ios_package.print() if dd_sdk_ios_package.version > 2: raise Exception( @@ -49,8 +50,8 @@ def dogfood(dry_run: bool, repository_url: str, repository_name: str, repository map(lambda path: PackageResolvedFile(path=path), repository_package_resolved_paths) ) - # Update version of `dd-sdk-ios`: for package in packages: + # Update version of `dd-sdk-ios`: package.update_dependency( package_id=PackageID(v1='DatadogSDK', v2='dd-sdk-ios'), new_branch='dogfooding', @@ -58,24 +59,24 @@ def dogfood(dry_run: bool, repository_url: str, repository_name: str, repository new_version=None ) - # Add or update `dd-sdk-ios` dependencies + # Add or update `dd-sdk-ios` dependencies: for dependency_id in dd_sdk_ios_package.read_dependency_ids(): dependency = dd_sdk_ios_package.read_dependency(package_id=dependency_id) if package.has_dependency(package_id=dependency_id): package.update_dependency( package_id=dependency_id, - new_branch=dependency['state']['branch'], + new_branch=dependency['state'].get('branch'), new_revision=dependency['state']['revision'], - new_version=dependency['state']['version'], + new_version=dependency['state'].get('version'), ) else: package.add_dependency( package_id=dependency_id, repository_url=dependency['repositoryURL'], - branch=dependency['state']['branch'], + branch=dependency['state'].get('branch'), revision=dependency['state']['revision'], - version=dependency['state']['version'] + version=dependency['state'].get('version'), ) package.save() diff --git a/tools/distribution/src/dogfood/package_resolved.py b/tools/distribution/src/dogfood/package_resolved.py index 105d30d3c2..a47dc2f8d6 100644 --- a/tools/distribution/src/dogfood/package_resolved.py +++ b/tools/distribution/src/dogfood/package_resolved.py @@ -165,6 +165,7 @@ def has_dependency(self, package_id: PackageID): return package_id.v1 in [p['package'] for p in pins] def update_dependency(self, package_id: PackageID, new_branch: Optional[str], new_revision: str, new_version: Optional[str]): + print(f'⚙️ ️ Updating "{package_id.v1}" in {self.path} (V1):') package = self.__get_package(package_id=package_id) old_state = deepcopy(package['state']) @@ -264,6 +265,7 @@ def has_dependency(self, package_id: PackageID): return package_id.v2 in [p['identity'] for p in pins] def update_dependency(self, package_id: PackageID, new_branch: Optional[str], new_revision: str, new_version: Optional[str]): + print(f'⚙️ ️ Updating "{package_id.v2}" in {self.path} (V2):') package = self.__get_package(package_id=package_id) old_state = deepcopy(package['state']) diff --git a/tools/rum-models-generator/Sources/CodeDecoration/RUMCodeDecorator.swift b/tools/rum-models-generator/Sources/CodeDecoration/RUMCodeDecorator.swift index def1aaff88..f551140b58 100644 --- a/tools/rum-models-generator/Sources/CodeDecoration/RUMCodeDecorator.swift +++ b/tools/rum-models-generator/Sources/CodeDecoration/RUMCodeDecorator.swift @@ -20,9 +20,12 @@ public class RUMCodeDecorator: SwiftCodeDecorator { "RUMMethod", "RUMEventAttributes", "RUMCITest", + "RUMSessionType", + "RUMSyntheticsTest", "RUMDevice", "RUMOperatingSystem", "RUMActionID", + "RUMSessionPrecondition", ] ) } @@ -92,6 +95,14 @@ public class RUMCodeDecorator: SwiftCodeDecorator { fixedName = "RUMCITest" } + if fixedName == "SessionType" { + fixedName = "RUMSessionType" + } + + if fixedName == "Synthetics" { + fixedName = "RUMSyntheticsTest" + } + if fixedName == "Device" { fixedName = "RUMDevice" } @@ -108,6 +119,10 @@ public class RUMCodeDecorator: SwiftCodeDecorator { fixedName = "RUMActionID" } + if fixedName == "SessionPrecondition" { + fixedName = "RUMSessionPrecondition" + } + return fixedName } } diff --git a/tools/rum-models-generator/Sources/CodeGeneration/Print/SwiftPrinter.swift b/tools/rum-models-generator/Sources/CodeGeneration/Print/SwiftPrinter.swift index cf91500bca..0544e52668 100644 --- a/tools/rum-models-generator/Sources/CodeGeneration/Print/SwiftPrinter.swift +++ b/tools/rum-models-generator/Sources/CodeGeneration/Print/SwiftPrinter.swift @@ -14,6 +14,7 @@ public class SwiftPrinter: BasePrinter, CodePrinter { case `public` /// Use to make all generated models visible in internal interface. case `internal` + case `spi` } /// Access level for for entities within printed code. @@ -66,6 +67,9 @@ public class SwiftPrinter: BasePrinter, CodePrinter { let conformance = implementedProtocols.isEmpty ? "" : ": \(implementedProtocols.joined(separator: ", "))" printComment(swiftStruct.comment) + if let attribute = configuration.accessLevel.attribute { + writeLine("\(attribute)") + } writeLine("\(configuration.accessLevel) struct \(swiftStruct.name)\(conformance) {") indentRight() try printPropertiesList(swiftStruct.properties) @@ -303,6 +307,9 @@ public class SwiftPrinter: BasePrinter, CodePrinter { }() printComment(enumeration.comment) + if let attribute = configuration.accessLevel.attribute { + writeLine("\(attribute)") + } writeLine("\(configuration.accessLevel) enum \(enumeration.name): \(rawValueType)\(conformance) {") indentRight() enumeration.cases.forEach { `case` in @@ -373,6 +380,9 @@ public class SwiftPrinter: BasePrinter, CodePrinter { let conformance = implementedProtocols.isEmpty ? "" : ": \(implementedProtocols.joined(separator: ", "))" printComment(enumeration.comment) + if let attribute = configuration.accessLevel.attribute { + writeLine("\(attribute)") + } writeLine("\(configuration.accessLevel) enum \(enumeration.name)\(conformance) {") indentRight() try enumeration.cases.forEach { `case` in @@ -476,6 +486,19 @@ extension SwiftPrinter.Configuration.AccessLevel: CustomStringConvertible { return "public" case .internal: return "internal" + case .spi: + return "public" + } + } + + public var attribute: String? { + switch self { + case .public: + return nil + case .internal: + return nil + case .spi: + return "@_spi(Internal)" } } } diff --git a/tools/rum-models-generator/Sources/rum-models-generator/GenerateSRModels.swift b/tools/rum-models-generator/Sources/rum-models-generator/GenerateSRModels.swift index 462d47c8eb..45f44aef82 100644 --- a/tools/rum-models-generator/Sources/rum-models-generator/GenerateSRModels.swift +++ b/tools/rum-models-generator/Sources/rum-models-generator/GenerateSRModels.swift @@ -19,6 +19,7 @@ internal func generateSRSwiftModels(from schema: URL) throws -> String { * Copyright 2019-Present Datadog, Inc. */ + #if os(iOS) import DatadogInternal // This file was generated from JSON Schema. Do not modify it directly. @@ -26,11 +27,13 @@ internal func generateSRSwiftModels(from schema: URL) throws -> String { internal protocol SRDataModel: Codable {} """, - footer: "" + footer: """ + #endif + """ ) let printer = SwiftPrinter( configuration: .init( - accessLevel: .internal + accessLevel: .spi ) ) diff --git a/tools/rum-models-generator/Tests/CodeGenerationTests/Print/SwiftPrinterTests.swift b/tools/rum-models-generator/Tests/CodeGenerationTests/Print/SwiftPrinterTests.swift index 6194790f7b..ab50ce2ff8 100644 --- a/tools/rum-models-generator/Tests/CodeGenerationTests/Print/SwiftPrinterTests.swift +++ b/tools/rum-models-generator/Tests/CodeGenerationTests/Print/SwiftPrinterTests.swift @@ -563,4 +563,99 @@ final class SwiftPrinterTests: XCTestCase { XCTAssertEqual(expected, actual) } + + func testPrintingSwiftStructAndEnumWithAttribute() throws { + let `struct` = SwiftStruct( + name: "Foo", + comment: "This comment should be above the attribute", + properties: [ + SwiftStruct.Property( + name: "context", + comment: nil, + type: SwiftDictionary( + value: SwiftCodable() + ), + isOptional: false, + mutability: .immutable, + defaultValue: nil, + codingKey: .dynamic + ), + SwiftStruct.Property( + name: "ignoredProperty", + comment: "This property will be ignored in coding because it uses default value and is immutable", + type: SwiftPrimitive(), + isOptional: false, + mutability: .immutable, + defaultValue: "default value", + codingKey: .static(value: "ignoredProperty") + ) + ], + conformance: [codableProtocol] + ) + + let `enum` = SwiftEnum( + name: "BizzBuzz", + comment: "This comment should be above the attribute", + cases: [ + SwiftEnum.Case(label: "case1", rawValue: .string(value: "case 1")), + SwiftEnum.Case(label: "case2", rawValue: .string(value: "case 2")), + SwiftEnum.Case(label: "case3", rawValue: .string(value: "case 3")), + ], + conformance: [codableProtocol] + ) + + let printer = SwiftPrinter(configuration: .init(accessLevel: .spi)) + let actual = try printer.print(swiftTypes: [`struct`, `enum`]) + + let expected = """ + + /// This comment should be above the attribute + @_spi(Internal) + public struct Foo: Codable { + public let context: [String: Codable] + + /// This property will be ignored in coding because it uses default value and is immutable + public let ignoredProperty: String = "default value" + + enum StaticCodingKeys: String, CodingKey { + case ignoredProperty = "ignoredProperty" + } + } + + extension Foo { + public func encode(to encoder: Encoder) throws { + // Encode dynamic properties: + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try context.forEach { + let key = DynamicCodingKey($0) + try dynamicContainer.encode(AnyEncodable($1), forKey: key) + } + } + + public init(from decoder: Decoder) throws { + // Decode other properties into [String: Codable] dictionary: + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + let dynamicKeys = dynamicContainer.allKeys + var dictionary: [String: Codable] = [:] + + try dynamicKeys.forEach { codingKey in + dictionary[codingKey.stringValue] = try dynamicContainer.decode(AnyCodable.self, forKey: codingKey) + } + + self.context = dictionary + } + } + + /// This comment should be above the attribute + @_spi(Internal) + public enum BizzBuzz: String, Codable { + case case1 = "case 1" + case case2 = "case 2" + case case3 = "case 3" + } + + """ + + XCTAssertEqual(expected, actual) + } } diff --git a/tools/rum-models-generator/run.py b/tools/rum-models-generator/run.py index b682139878..ebadc2fa21 100755 --- a/tools/rum-models-generator/run.py +++ b/tools/rum-models-generator/run.py @@ -24,7 +24,7 @@ # Generated file paths (relative to repository root) RUM_SWIFT_GENERATED_FILE_PATH = '/DatadogRUM/Sources/DataModels/RUMDataModels.swift' RUM_OBJC_GENERATED_FILE_PATH = '/DatadogObjc/Sources/RUM/RUMDataModels+objc.swift' -SR_SWIFT_GENERATED_FILE_PATH = '/DatadogSessionReplay/Sources/Writer/Models/SRDataModels.swift' +SR_SWIFT_GENERATED_FILE_PATH = '/DatadogSessionReplay/Sources/Models/SRDataModels.swift' @dataclass class Context: