From 9e356d44ca27fcd4898360432a9c9feab12019e5 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Fri, 20 Sep 2024 18:03:02 +0200 Subject: [PATCH 01/43] RUM-6245 Improve image hashing memory footprint --- Datadog/Datadog.xcodeproj/project.pbxproj | 32 ++-- .../Utilities/UIColor+SessionReplay.swift | 44 ++++++ .../Utilities/UIImage+SessionReplay.swift | 142 +++++++++++++----- ...sions.swift => UIView+SessionReplay.swift} | 0 .../NodeRecorders/UIImageResource.swift | 23 +-- .../Sources/Utilities/UIImage+Scaling.swift | 84 ----------- .../Tests/Mocks/UIKitMocks.swift | 8 +- ....swift => UIView+SessionReplayTests.swift} | 2 +- .../NodeRecorders/UIImageResourceTests.swift | 5 +- ...swift => UIImage+SessionReplayTests.swift} | 22 ++- 10 files changed, 184 insertions(+), 178 deletions(-) create mode 100644 DatadogSessionReplay/Sources/Recorder/Utilities/UIColor+SessionReplay.swift rename DatadogSessionReplay/Sources/Recorder/Utilities/{UIKitExtensions.swift => UIView+SessionReplay.swift} (100%) delete mode 100644 DatadogSessionReplay/Sources/Utilities/UIImage+Scaling.swift rename DatadogSessionReplay/Tests/Recorder/Utilties/{UIKitExtensionsTests.swift => UIView+SessionReplayTests.swift} (98%) rename DatadogSessionReplay/Tests/Utilities/{UIImage+ScalingTests.swift => UIImage+SessionReplayTests.swift} (56%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index c931e8ed42..260354e8cf 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 61054E672A6EE10A00AAA894 /* Recorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E112A6EE10A00AAA894 /* Recorder.swift */; }; 61054E682A6EE10A00AAA894 /* PrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E122A6EE10A00AAA894 /* PrivacyLevel.swift */; }; 61054E692A6EE10A00AAA894 /* UIImage+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */; }; - 61054E6A2A6EE10A00AAA894 /* UIKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */; }; + 61054E6A2A6EE10A00AAA894 /* UIView+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */; }; 61054E6B2A6EE10A00AAA894 /* CFType+Safety.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */; }; 61054E6C2A6EE10A00AAA894 /* SystemColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E172A6EE10A00AAA894 /* SystemColors.swift */; }; 61054E6D2A6EE10A00AAA894 /* CGRect+ContentFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */; }; @@ -202,14 +202,13 @@ 61054E992A6EE10A00AAA894 /* WireframesBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E512A6EE10A00AAA894 /* WireframesBuilder.swift */; }; 61054E9A2A6EE10A00AAA894 /* NodesFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E532A6EE10A00AAA894 /* NodesFlattener.swift */; }; 61054E9B2A6EE10B00AAA894 /* CGRectExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E552A6EE10A00AAA894 /* CGRectExtensions.swift */; }; - 61054E9C2A6EE10B00AAA894 /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E562A6EE10A00AAA894 /* UIImage+Scaling.swift */; }; 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E582A6EE10A00AAA894 /* Queue.swift */; }; 61054E9F2A6EE10B00AAA894 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E592A6EE10A00AAA894 /* Errors.swift */; }; 61054EA02A6EE10B00AAA894 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E5A2A6EE10A00AAA894 /* Colors.swift */; }; 61054EA12A6EE10B00AAA894 /* MainThreadScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E5C2A6EE10A00AAA894 /* MainThreadScheduler.swift */; }; 61054EA22A6EE10B00AAA894 /* Scheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054E5D2A6EE10A00AAA894 /* Scheduler.swift */; }; 61054F952A6EE1BA00AAA894 /* SessionReplayConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */; }; - 61054F972A6EE1BA00AAA894 /* UIImage+ScalingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F402A6EE1B900AAA894 /* UIImage+ScalingTests.swift */; }; + 61054F972A6EE1BA00AAA894 /* UIImage+SessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F402A6EE1B900AAA894 /* UIImage+SessionReplayTests.swift */; }; 61054F982A6EE1BA00AAA894 /* CGRectExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F412A6EE1B900AAA894 /* CGRectExtensionsTests.swift */; }; 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F422A6EE1B900AAA894 /* ColorsTests.swift */; }; 61054F9A2A6EE1BA00AAA894 /* CFType+SafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F432A6EE1B900AAA894 /* CFType+SafetyTests.swift */; }; @@ -226,7 +225,7 @@ 61054FA62A6EE1BA00AAA894 /* SnapshotProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F562A6EE1BA00AAA894 /* SnapshotProcessorTests.swift */; }; 61054FA72A6EE1BA00AAA894 /* NodesFlattenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F582A6EE1BA00AAA894 /* NodesFlattenerTests.swift */; }; 61054FA82A6EE1BA00AAA894 /* RecordingCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F5A2A6EE1BA00AAA894 /* RecordingCoordinatorTests.swift */; }; - 61054FAA2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F5D2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift */; }; + 61054FAA2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F5D2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift */; }; 61054FAC2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F5F2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift */; }; 61054FAD2A6EE1BA00AAA894 /* WindowTouchSnapshotProducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F612A6EE1BA00AAA894 /* WindowTouchSnapshotProducerTests.swift */; }; 61054FAE2A6EE1BA00AAA894 /* TouchIdentifierGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61054F632A6EE1BA00AAA894 /* TouchIdentifierGeneratorTests.swift */; }; @@ -839,6 +838,7 @@ D2216EC12A94DE2900ADAEC8 /* FeatureBaggage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2216EBF2A94DE2800ADAEC8 /* FeatureBaggage.swift */; }; D2216EC32A96649500ADAEC8 /* FeatureBaggageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2216EC22A96632F00ADAEC8 /* FeatureBaggageTests.swift */; }; D2216EC42A96649700ADAEC8 /* FeatureBaggageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2216EC22A96632F00ADAEC8 /* FeatureBaggageTests.swift */; }; + D22442C52CA301DA002E71E4 /* UIColor+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */; }; D224430429E9588100274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; D224430629E95C2C00274EC7 /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA429E072D7004D0AE8 /* MessageBus.swift */; }; @@ -2189,7 +2189,7 @@ 61054E112A6EE10A00AAA894 /* Recorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Recorder.swift; sourceTree = ""; }; 61054E122A6EE10A00AAA894 /* PrivacyLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivacyLevel.swift; sourceTree = ""; }; 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+SessionReplay.swift"; sourceTree = ""; }; - 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitExtensions.swift; sourceTree = ""; }; + 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+SessionReplay.swift"; sourceTree = ""; }; 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CFType+Safety.swift"; sourceTree = ""; }; 61054E172A6EE10A00AAA894 /* SystemColors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemColors.swift; sourceTree = ""; }; 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGRect+ContentFrame.swift"; sourceTree = ""; }; @@ -2236,14 +2236,13 @@ 61054E512A6EE10A00AAA894 /* WireframesBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WireframesBuilder.swift; sourceTree = ""; }; 61054E532A6EE10A00AAA894 /* NodesFlattener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NodesFlattener.swift; sourceTree = ""; }; 61054E552A6EE10A00AAA894 /* CGRectExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGRectExtensions.swift; sourceTree = ""; }; - 61054E562A6EE10A00AAA894 /* UIImage+Scaling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; 61054E582A6EE10A00AAA894 /* Queue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; 61054E592A6EE10A00AAA894 /* Errors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; 61054E5A2A6EE10A00AAA894 /* Colors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; 61054E5C2A6EE10A00AAA894 /* MainThreadScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainThreadScheduler.swift; sourceTree = ""; }; 61054E5D2A6EE10A00AAA894 /* Scheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Scheduler.swift; sourceTree = ""; }; 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayConfigurationTests.swift; sourceTree = ""; }; - 61054F402A6EE1B900AAA894 /* UIImage+ScalingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+ScalingTests.swift"; sourceTree = ""; }; + 61054F402A6EE1B900AAA894 /* UIImage+SessionReplayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+SessionReplayTests.swift"; sourceTree = ""; }; 61054F412A6EE1B900AAA894 /* CGRectExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGRectExtensionsTests.swift; sourceTree = ""; }; 61054F422A6EE1B900AAA894 /* ColorsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorsTests.swift; sourceTree = ""; }; 61054F432A6EE1B900AAA894 /* CFType+SafetyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CFType+SafetyTests.swift"; sourceTree = ""; }; @@ -2260,7 +2259,7 @@ 61054F562A6EE1BA00AAA894 /* SnapshotProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotProcessorTests.swift; sourceTree = ""; }; 61054F582A6EE1BA00AAA894 /* NodesFlattenerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NodesFlattenerTests.swift; sourceTree = ""; }; 61054F5A2A6EE1BA00AAA894 /* RecordingCoordinatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecordingCoordinatorTests.swift; sourceTree = ""; }; - 61054F5D2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitExtensionsTests.swift; sourceTree = ""; }; + 61054F5D2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+SessionReplayTests.swift"; sourceTree = ""; }; 61054F5F2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGRect+ContentFrameTests.swift"; sourceTree = ""; }; 61054F612A6EE1BA00AAA894 /* WindowTouchSnapshotProducerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowTouchSnapshotProducerTests.swift; sourceTree = ""; }; 61054F632A6EE1BA00AAA894 /* TouchIdentifierGeneratorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchIdentifierGeneratorTests.swift; sourceTree = ""; }; @@ -2838,6 +2837,7 @@ D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiverTests.swift; sourceTree = ""; }; D2216EBF2A94DE2800ADAEC8 /* FeatureBaggage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureBaggage.swift; sourceTree = ""; }; D2216EC22A96632F00ADAEC8 /* FeatureBaggageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureBaggageTests.swift; sourceTree = ""; }; + D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SessionReplay.swift"; sourceTree = ""; }; D224430C29E95D6600274EC7 /* CrashReportReceiverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReportReceiverTests.swift; sourceTree = ""; }; D22743E829DEC9A9001A7EF9 /* RUMDataModelMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDataModelMocks.swift; sourceTree = ""; }; D227A0A32C7622EA00C83324 /* BenchmarkProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchmarkProfiler.swift; sourceTree = ""; }; @@ -3613,8 +3613,9 @@ 61054E132A6EE10A00AAA894 /* Utilities */ = { isa = PBXGroup; children = ( + 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */, 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */, - 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */, + D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */, 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */, 61054E172A6EE10A00AAA894 /* SystemColors.swift */, 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */, @@ -3785,7 +3786,6 @@ isa = PBXGroup; children = ( 61054E552A6EE10A00AAA894 /* CGRectExtensions.swift */, - 61054E562A6EE10A00AAA894 /* UIImage+Scaling.swift */, 61054E582A6EE10A00AAA894 /* Queue.swift */, 61054E592A6EE10A00AAA894 /* Errors.swift */, 61054E5A2A6EE10A00AAA894 /* Colors.swift */, @@ -3807,7 +3807,7 @@ 61054F3E2A6EE1B900AAA894 /* Utilities */ = { isa = PBXGroup; children = ( - 61054F402A6EE1B900AAA894 /* UIImage+ScalingTests.swift */, + 61054F402A6EE1B900AAA894 /* UIImage+SessionReplayTests.swift */, 61054F412A6EE1B900AAA894 /* CGRectExtensionsTests.swift */, 61054F422A6EE1B900AAA894 /* ColorsTests.swift */, 61054F432A6EE1B900AAA894 /* CFType+SafetyTests.swift */, @@ -3899,7 +3899,7 @@ 61054F5B2A6EE1BA00AAA894 /* Utilties */ = { isa = PBXGroup; children = ( - 61054F5D2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift */, + 61054F5D2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift */, 61054F5F2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift */, ); path = Utilties; @@ -8352,14 +8352,13 @@ 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 */, 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */, 61054E772A6EE10A00AAA894 /* ViewTreeRecorder.swift in Sources */, 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */, 61054E872A6EE10A00AAA894 /* ViewAttributes+Copy.swift in Sources */, - 61054E6A2A6EE10A00AAA894 /* UIKitExtensions.swift in Sources */, + 61054E6A2A6EE10A00AAA894 /* UIView+SessionReplay.swift in Sources */, 61054E7D2A6EE10A00AAA894 /* UITextFieldRecorder.swift in Sources */, 61054E832A6EE10A00AAA894 /* UISwitchRecorder.swift in Sources */, 61054E9A2A6EE10A00AAA894 /* NodesFlattener.swift in Sources */, @@ -8391,6 +8390,7 @@ 61054E7F2A6EE10A00AAA894 /* UISliderRecorder.swift in Sources */, 61054E842A6EE10A00AAA894 /* UITabBarRecorder.swift in Sources */, 61054E682A6EE10A00AAA894 /* PrivacyLevel.swift in Sources */, + D22442C52CA301DA002E71E4 /* UIColor+SessionReplay.swift in Sources */, 61054E8E2A6EE10A00AAA894 /* SRContextPublisher.swift in Sources */, 61054E732A6EE10A00AAA894 /* WindowTouchSnapshotProducer.swift in Sources */, A70ADCD22B583B1300321BC9 /* UIImageResource.swift in Sources */, @@ -8460,14 +8460,14 @@ 61054FC92A6EE1BA00AAA894 /* RecorderMocks.swift in Sources */, 61054FBB2A6EE1BA00AAA894 /* UISwitchRecorderTests.swift in Sources */, A7F651302B7655DE004B0EDB /* UIImageResourceTests.swift in Sources */, - 61054F972A6EE1BA00AAA894 /* UIImage+ScalingTests.swift in Sources */, + 61054F972A6EE1BA00AAA894 /* UIImage+SessionReplayTests.swift in Sources */, 61054FB02A6EE1BA00AAA894 /* ViewTreeSnapshotBuilderTests.swift in Sources */, 61054FD32A6EE1BA00AAA894 /* MultipartFormDataTests.swift in Sources */, 61054FB12A6EE1BA00AAA894 /* NodeIDGeneratorTests.swift in Sources */, 61054F9C2A6EE1BA00AAA894 /* SwiftExtensionsTests.swift in Sources */, 61054FA72A6EE1BA00AAA894 /* NodesFlattenerTests.swift in Sources */, 61054F9D2A6EE1BA00AAA894 /* MainThreadSchedulerTests.swift in Sources */, - 61054FAA2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift in Sources */, + 61054FAA2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift in Sources */, 61054FA52A6EE1BA00AAA894 /* RecordsBuilderTests.swift in Sources */, 61054FD02A6EE1BA00AAA894 /* SRContextPublisherTests.swift in Sources */, 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/UIColor+SessionReplay.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/UIColor+SessionReplay.swift new file mode 100644 index 0000000000..447c141bcd --- /dev/null +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/UIColor+SessionReplay.swift @@ -0,0 +1,44 @@ +/* + * 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 UIKit +import DatadogInternal + +extension UIColor: DatadogExtended {} + +private var identifierKey: UInt8 = 0 + +extension DatadogExtension where ExtendedType: UIColor { + var identifier: String { + if let hash = objc_getAssociatedObject(type, &identifierKey) as? String { + return hash + } + + let hash = computeIdentifier() + objc_setAssociatedObject(type, &identifierKey, hash, .OBJC_ASSOCIATION_RETAIN) + return hash + } + + private func computeIdentifier() -> String { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + type.getRed(&r, green: &g, blue: &b, alpha: &a) + return String( + format: "%02X%02X%02X%02X", + Int(round(r * 255)), + Int(round(g * 255)), + Int(round(b * 255)), + Int(round(a * 255)) + ) + } +} + +#endif diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/UIImage+SessionReplay.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/UIImage+SessionReplay.swift index 8a0e586d36..a57edd4aaa 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/UIImage+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/UIImage+SessionReplay.swift @@ -8,59 +8,127 @@ import Foundation import UIKit import DatadogInternal -import CryptoKit +import CommonCrypto -private var srIdentifierKey: UInt8 = 11 +private let bitsPerComponent = 8 +private var identifierKey: UInt8 = 0 extension UIImage: DatadogExtended {} + extension DatadogExtension where ExtendedType: UIImage { - var srIdentifier: String { - if let hash = objc_getAssociatedObject(type, &srIdentifierKey) as? String { - return hash - } else { - let hash = computeHash() - objc_setAssociatedObject(type, &srIdentifierKey, hash, .OBJC_ASSOCIATION_RETAIN) + var identifier: String { + if let hash = objc_getAssociatedObject(type, &identifierKey) as? String { return hash } + + let hash = md5Digest() ?? "\(type.hash)" + objc_setAssociatedObject(type, &identifierKey, hash, .OBJC_ASSOCIATION_RETAIN) + return hash } - private func computeHash() -> String { - guard let imageData = type.pngData() else { - return "" + /// Generate a MD5 digest based on the image pixels. + /// + /// The digest value is computed in a greyscale image on a 100 pixels size maximum + /// (width or height) + /// + /// - Returns: The MD5 digest + private func md5Digest() -> String? { + guard let cgImage = type.cgImage else { + return nil + } + + // rescale the image to maximum of 100 pixels width or height + let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height)) + let ratio = max(1, size.width / 100, size.height / 100) + + let rect = CGRect( + origin: .zero, + size: CGSize( + width: size.width / ratio, + height: size.height / ratio + ) + ) + + // create a greyscale context + let context = CGContext( + data: nil, + width: Int(rect.width), + height: Int(rect.height), + bitsPerComponent: bitsPerComponent, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + + guard let context = context else { + return nil } - if #available(iOS 13.0, *) { - return Insecure.MD5.hash(data: imageData).map { String(format: "%02hhx", $0) }.joined() - } else { - return "\(type.hash)" + + // draw the image with low quality interpolation + context.interpolationQuality = .low + context.draw(cgImage, in: rect) + + guard let rawData = context.data else { + return nil } + + // compute MD5 digest on the context data + let length = context.bytesPerRow * context.height + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + CC_MD5(rawData, UInt32(length), &digest) + return digest.map { String(format: "%02hhx", $0) }.joined() } -} -extension UIColor: DatadogExtended {} -extension DatadogExtension where ExtendedType: UIColor { - var srIdentifier: String { - if let hash = objc_getAssociatedObject(type, &srIdentifierKey) as? String { - return hash - } else { - let hash = computeIdentifier() - objc_setAssociatedObject(type, &srIdentifierKey, hash, .OBJC_ASSOCIATION_RETAIN) - return hash + /// Compress the image to PNG. + /// + /// Scale down the image an apply tint color if necessary. + /// + /// - Parameters: + /// - maxSize: The maximum size of the image. + /// - tintColor: The tint color to apply. + /// - Returns: The PNG data. + func pngData(maxSize: CGSize = .init(width: 1_000, height: 1_000), tintColor: UIColor? = nil) -> Data? { + if #available(iOS 13.0, *), type.isSymbolImage, let tintColor = tintColor { + return png(image: type.withTintColor(tintColor), maxSize: maxSize, tintColor: nil) } + + return png(image: type, maxSize: maxSize, tintColor: tintColor) } - private func computeIdentifier() -> String { - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - var a: CGFloat = 0 - type.getRed(&r, green: &g, blue: &b, alpha: &a) - return String( - format: "%02X%02X%02X%02X", - Int(round(r * 255)), - Int(round(g * 255)), - Int(round(b * 255)), - Int(round(a * 255)) + /// Compress an image to PNG. + /// + /// Scale down the image an apply tint color if necessary. + /// + /// - Parameters: + /// - image: The image to compress. + /// - maxSize: The maximum size of the image. + /// - tintColor: The tint color to apply. + /// - Returns: The PNG data. + private func png(image: UIImage, maxSize: CGSize, tintColor: UIColor?) -> Data? { + let ratio = max(1, image.size.width / maxSize.width, image.size.height / maxSize.height) + + guard tintColor != nil || ratio > 1 else { + return image.pngData() + } + + let rect = CGRect( + origin: .zero, + size: CGSize( + width: image.size.width / ratio, + height: image.size.height / ratio + ) ) + + let renderer = UIGraphicsImageRenderer(size: rect.size) + return renderer.pngData { context in + if let tintColor = tintColor { + tintColor.setFill() + context.fill(rect) + } + + image.draw(in: rect, blendMode: .destinationIn, alpha: 1.0) + } } } + #endif diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/UIKitExtensions.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/UIView+SessionReplay.swift similarity index 100% rename from DatadogSessionReplay/Sources/Recorder/Utilities/UIKitExtensions.swift rename to DatadogSessionReplay/Sources/Recorder/Utilities/UIView+SessionReplay.swift diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResource.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResource.swift index 0087b64127..30e825d2aa 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResource.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResource.swift @@ -6,6 +6,7 @@ #if os(iOS) import UIKit +import DatadogInternal internal struct UIImageResource { private let image: UIImage @@ -19,29 +20,11 @@ internal struct UIImageResource { extension UIImageResource: Resource { func calculateIdentifier() -> String { - var identifier = image.dd.srIdentifier - if let tintColorIdentifier = tintColor?.dd.srIdentifier { - identifier += tintColorIdentifier - } - return identifier + tintColor.map { image.dd.identifier + $0 .dd.identifier } ?? image.dd.identifier } func calculateData() -> Data { - guard let tintColor = tintColor else { - return image.scaledDownToApproximateSize(SessionReplay.maxObjectSize) - } - if #available(iOS 13.0, *), image.isSymbolImage { - return image.withTintColor(tintColor) - .scaledDownToApproximateSize(SessionReplay.maxObjectSize) - } else { - return manuallyTintedImageData() - } - } - - /// Provides mitigation for template images that fail to tint programatically outside of `UIImageView` or other system container. - private func manuallyTintedImageData() -> Data { - return image - .scaledDownToApproximateSize(SessionReplay.maxObjectSize, tint: tintColor) + image.dd.pngData(tintColor: tintColor) ?? Data() } } #endif diff --git a/DatadogSessionReplay/Sources/Utilities/UIImage+Scaling.swift b/DatadogSessionReplay/Sources/Utilities/UIImage+Scaling.swift deleted file mode 100644 index 5655da6e32..0000000000 --- a/DatadogSessionReplay/Sources/Utilities/UIImage+Scaling.swift +++ /dev/null @@ -1,84 +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. - */ - -#if os(iOS) -import UIKit - -extension UIImage { - /// Scales down the image to an approximate file size in bytes. - /// - /// - Parameter desiredSizeInBytes: The target file size in bytes. - /// - Returns: A Data object representing the scaled down image as PNG data. - /// - /// This function takes the desired file size in bytes as input and scales down the image iteratively - /// until the resulting PNG data size is less than or equal to the specified target size. - /// - /// Note: The function will return the original image data if it is already smaller than the desired size, - /// or if it fails to generate a smaller image. - /// - /// Example usage: - /// - /// let originalImage = UIImage(named: "exampleImage") - /// let desiredSizeInBytes = 10240 // 10 KB - /// if let imageData = originalImage?.scaledDownToApproximateSize(desiredSizeInBytes) { - /// // Use the scaled down image data. - /// } - func scaledDownToApproximateSize(_ desiredSizeInBytes: UInt64, tint: UIColor? = nil) -> Data { - guard let imageData = pngData() else { - return Data() - } - guard tint != nil || imageData.count > desiredSizeInBytes else { - return imageData - } - // Initial scale is approximatation based on the average side of square for given size ratio. - // When running experiments it appeared to be closer to desired scale than using just a size ratio. - let initialScale = min(1, sqrt(CGFloat(desiredSizeInBytes) / CGFloat(imageData.count))) - var scaledImage = scaledImage(by: initialScale, tint: tint) - - var scale: Double = 1 - let maxIterations = 20 - for _ in 0...maxIterations { - guard let scaledImageData = scaledImage.pngData() else { - return imageData - } - if scaledImageData.count <= desiredSizeInBytes { - return scaledImageData - } - scale *= 0.9 - scaledImage = scaledImage.scaledImage(by: scale, tint: tint) - } - guard let scaledImageData = scaledImage.pngData() else { - return imageData - } - return scaledImageData.count < imageData.count ? scaledImageData : imageData - } - - /// Scales the image by a given percentage. - /// - /// - Parameter percentage: The scaling factor to apply, where 1.0 represents the original size. - /// - Returns: A UIImage object representing the scaled image, or an empty UIImage if the percentage is less than or equal to zero. - /// - /// This private helper function takes a CGFloat percentage as input and scales the image accordingly. - /// It ensures that the resulting image has a size proportional to the original one, maintaining its aspect ratio. - private func scaledImage(by percentage: CGFloat, tint: UIColor?) -> UIImage { - guard percentage > 0 else { - return UIImage() - } - let newSize = CGSize(width: size.width * percentage, height: size.height * percentage) - UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0) - let drawRect = CGRect(origin: .zero, size: newSize) - if let tint = tint { - tint.setFill() - UIRectFill(drawRect) - } - draw(in: drawRect, blendMode: .destinationIn, alpha: 1.0) - let scaledImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return scaledImage ?? UIImage() - } -} -#endif diff --git a/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift b/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift index 2607b80e52..c14abffd1c 100644 --- a/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift @@ -116,19 +116,17 @@ extension UIImage: RandomMockable { let bitsPerComponent: Int = 8 let bytesPerRow = bytesPerPixel * width let totalBytes = bytesPerRow * height - let colorSpace = CGColorSpaceCreateDeviceRGB() var bitmapBytes = [UInt8].mockRandom(count: totalBytes) - let bitmapData = Data(bytes: &bitmapBytes, count: totalBytes) let bitmapContext = CGContext( - data: UnsafeMutableRawPointer(mutating: (bitmapData as NSData).bytes), + data: &bitmapBytes, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) let cgImage = bitmapContext!.makeImage()! return UIImage(cgImage: cgImage) as! Self diff --git a/DatadogSessionReplay/Tests/Recorder/Utilties/UIKitExtensionsTests.swift b/DatadogSessionReplay/Tests/Recorder/Utilties/UIView+SessionReplayTests.swift similarity index 98% rename from DatadogSessionReplay/Tests/Recorder/Utilties/UIKitExtensionsTests.swift rename to DatadogSessionReplay/Tests/Recorder/Utilties/UIView+SessionReplayTests.swift index 6683b263a1..94a5e75f36 100644 --- a/DatadogSessionReplay/Tests/Recorder/Utilties/UIKitExtensionsTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/Utilties/UIView+SessionReplayTests.swift @@ -11,7 +11,7 @@ import DatadogInternal import TestUtilities @testable import DatadogSessionReplay -class UIKitExtensionsTests: XCTestCase { +class UIViewSessionReplayTests: XCTestCase { func testUsesDarkMode() { guard #available(iOS 13.0, *) else { XCTAssertFalse(UIView().dd.usesDarkMode) // always false prior to iOS 13.x diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResourceTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResourceTests.swift index 5ed3b5ac32..9987a277cb 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResourceTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageResourceTests.swift @@ -11,9 +11,10 @@ import XCTest // Soft checks, because different iOS versions may produce different image data or hashing final class UIImageResourceTests: XCTestCase { func testWhenUIImageResourceIsInitializedWithEmptyImage() { - let imageResource = UIImageResource(image: UIImage(), tintColor: nil) + let image = UIImage() + let imageResource = UIImageResource(image: image, tintColor: nil) - XCTAssertEqual(imageResource.calculateIdentifier(), "") + XCTAssertEqual(imageResource.calculateIdentifier(), "\(image.hash)") XCTAssertEqual(imageResource.calculateData(), Data()) } diff --git a/DatadogSessionReplay/Tests/Utilities/UIImage+ScalingTests.swift b/DatadogSessionReplay/Tests/Utilities/UIImage+SessionReplayTests.swift similarity index 56% rename from DatadogSessionReplay/Tests/Utilities/UIImage+ScalingTests.swift rename to DatadogSessionReplay/Tests/Utilities/UIImage+SessionReplayTests.swift index 69f875d795..1cc4db189b 100644 --- a/DatadogSessionReplay/Tests/Utilities/UIImage+ScalingTests.swift +++ b/DatadogSessionReplay/Tests/Utilities/UIImage+SessionReplayTests.swift @@ -7,27 +7,23 @@ #if os(iOS) import Foundation import XCTest + @testable import DatadogSessionReplay -class UIImageScalingTests: XCTestCase { +class UIImageSessionReplayTests: XCTestCase { func testScaledToApproximateSize_ReturnsOriginalImageData_IfSizeIsSmallerOrEqualToAnticipatedMaxSize() throws { let image: UIImage = .mockRandom(width: 50, height: 50) - let pngData = try XCTUnwrap(image.pngData()) - let dataSize = pngData.count - - let maxSize = dataSize + 100 - let scaledData = image.scaledDownToApproximateSize(UInt64(maxSize)) - XCTAssertEqual(scaledData, pngData) + let imageData = try XCTUnwrap(image.pngData()) + let scaledData = try XCTUnwrap(image.dd.pngData(maxSize: CGSize(width: 100, height: 100))) + XCTAssertEqual(scaledData.count, imageData.count) } func testScaledToApproximateSize_ScalesImageToSmallerSize_IfSizeIsLargerThanAnticipatedMaxSize() throws { let image: UIImage = .mockRandom(width: 50, height: 50) - let pngData = try XCTUnwrap(image.pngData()) - let dataSize = pngData.count - - let maxSize = dataSize - 100 - let scaledData = image.scaledDownToApproximateSize(UInt64(maxSize)) - XCTAssertTrue(scaledData.count < dataSize) + let imageData = try XCTUnwrap(image.pngData()) + let scaledData = try XCTUnwrap(image.dd.pngData(maxSize: CGSize(width: 25, height: 25))) + XCTAssertLessThan(scaledData.count, imageData.count) } } + #endif From 154505ac8a1863394386621ad36df106af63cfe7 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 26 Sep 2024 18:36:08 +0200 Subject: [PATCH 02/43] Expose Start/Stop API to Objc --- .../ObjcAPITests/DDSessionReplay+apiTests.m | 4 ++++ .../Sources/SessionReplay+objc.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index c65a6e7113..f6e62802ed 100644 --- a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m @@ -31,4 +31,8 @@ - (void)testConfigurationWithNewApi { [DDSessionReplay enableWith:configuration]; } +- (void)testStartAndStopRecording { + [DDSessionReplay startRecording]; + [DDSessionReplay stopRecording]; +} @end diff --git a/DatadogSessionReplay/Sources/SessionReplay+objc.swift b/DatadogSessionReplay/Sources/SessionReplay+objc.swift index 0286475a8f..139f530970 100644 --- a/DatadogSessionReplay/Sources/SessionReplay+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplay+objc.swift @@ -26,6 +26,18 @@ public final class DDSessionReplay: NSObject { public static func enable(with configuration: DDSessionReplayConfiguration) { SessionReplay.enable(with: configuration._swift) } + + /// Starts the recording manually. + @objc + public static func startRecording() { + SessionReplay.startRecording(in: CoreRegistry.default) + } + + /// Stops the recording manually. + @objc + public static func stopRecording() { + SessionReplay.stopRecording(in: CoreRegistry.default) + } } /// Session Replay feature configuration. From 711842aa15e28746d4a9b01bbb77691194b0854b Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov Date: Mon, 30 Sep 2024 09:16:58 +0200 Subject: [PATCH 03/43] Update Session Replay schema with a Kotlin Multiplatform source for Mobile segment --- .../Sources/Models/SRDataModels.swift | 3 +- api-surface-objc | 33 ++++ api-surface-swift | 150 +++++++++++++++++- 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/DatadogSessionReplay/Sources/Models/SRDataModels.swift b/DatadogSessionReplay/Sources/Models/SRDataModels.swift index 08d98e640e..df3b795996 100644 --- a/DatadogSessionReplay/Sources/Models/SRDataModels.swift +++ b/DatadogSessionReplay/Sources/Models/SRDataModels.swift @@ -86,6 +86,7 @@ public struct SRSegment: SRDataModel { case ios = "ios" case flutter = "flutter" case reactNative = "react-native" + case kotlinMultiplatform = "kotlin-multiplatform" } /// View properties @@ -1330,4 +1331,4 @@ public enum SRRecord: Codable { } } #endif -// Generated from https://github.com/DataDog/rum-events-format/tree/500d32fb7d050f634cf417c28f1e0b7181b2c704 +// Generated from https://github.com/DataDog/rum-events-format/tree/6442ab65ccecbf5061e70edb5da9f94fecd574ea diff --git a/api-surface-objc b/api-surface-objc index 42e2742185..fbeea6cb9c 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -980,9 +980,39 @@ public class DDRUMLongTaskEventDisplayViewport: NSObject @objc public var height: NSNumber @objc public var width: NSNumber public class DDRUMLongTaskEventLongTask: NSObject + @objc public var blockingDuration: NSNumber? @objc public var duration: NSNumber + @objc public var entryType: DDRUMLongTaskEventLongTaskEntryType + @objc public var firstUiEventTimestamp: NSNumber? @objc public var id: String? @objc public var isFrozenFrame: NSNumber? + @objc public var renderStart: NSNumber? + @objc public var scripts: [DDRUMLongTaskEventLongTaskScripts]? + @objc public var styleAndLayoutStart: NSNumber? +public enum DDRUMLongTaskEventLongTaskEntryType: Int + case none + case longTask + case longAnimationFrame +public class DDRUMLongTaskEventLongTaskScripts: NSObject + @objc public var duration: NSNumber? + @objc public var executionStart: NSNumber? + @objc public var forcedStyleAndLayoutDuration: NSNumber? + @objc public var invoker: String? + @objc public var invokerType: DDRUMLongTaskEventLongTaskScriptsInvokerType + @objc public var pauseDuration: NSNumber? + @objc public var sourceCharPosition: NSNumber? + @objc public var sourceFunctionName: String? + @objc public var sourceUrl: String? + @objc public var startTime: NSNumber? + @objc public var windowAttribution: String? +public enum DDRUMLongTaskEventLongTaskScriptsInvokerType: Int + case none + case userCallback + case eventListener + case resolvePromise + case rejectPromise + case classicScript + case moduleScript public class DDRUMLongTaskEventRUMOperatingSystem: NSObject @objc public var build: String? @objc public var name: String @@ -1852,6 +1882,7 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject @objc public var forwardConsoleLogs: DDTelemetryConfigurationEventTelemetryConfigurationForwardConsoleLogs? @objc public var forwardErrorsToLogs: NSNumber? @objc public var forwardReports: DDTelemetryConfigurationEventTelemetryConfigurationForwardReports? + @objc public var imagePrivacyLevel: String? @objc public var initializationType: String? @objc public var mobileVitalsUpdatePeriod: NSNumber? @objc public var plugins: [DDTelemetryConfigurationEventTelemetryConfigurationPlugins]? @@ -1870,6 +1901,8 @@ public class DDTelemetryConfigurationEventTelemetryConfiguration: NSObject @objc public var telemetryConfigurationSampleRate: NSNumber? @objc public var telemetrySampleRate: NSNumber? @objc public var telemetryUsageSampleRate: NSNumber? + @objc public var textAndInputPrivacyLevel: String? + @objc public var touchPrivacyLevel: String? @objc public var traceContextInjection: DDTelemetryConfigurationEventTelemetryConfigurationTraceContextInjection @objc public var traceSampleRate: NSNumber? @objc public var tracerApi: String? diff --git a/api-surface-swift b/api-surface-swift index 49b8d3db02..aa258a9808 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -148,7 +148,7 @@ public enum LogLevel: Int, Codable case critical [?] extension CoreLoggerLevel public init(logLevel: LogLevel) -public protocol LoggerProtocol +public protocol LoggerProtocol: Sendable func log(level: LogLevel, message: String, error: Error?, attributes: [String: Encodable]?) func addAttribute(forKey key: AttributeKey, value: AttributeValue) func removeAttribute(forKey key: AttributeKey) @@ -708,9 +708,37 @@ public struct RUMLongTaskEvent: RUMDataModel public let height: Double public let width: Double public struct LongTask: Codable + public let blockingDuration: Int64? public let duration: Int64 + public let entryType: EntryType? + public let firstUiEventTimestamp: Double? public let id: String? public let isFrozenFrame: Bool? + public let renderStart: Double? + public let scripts: [Scripts]? + public let styleAndLayoutStart: Double? + public enum EntryType: String, Codable + case longTask = "long-task" + case longAnimationFrame = "long-animation-frame" + public struct Scripts: Codable + public let duration: Int64? + public let executionStart: Double? + public let forcedStyleAndLayoutDuration: Int64? + public let invoker: String? + public let invokerType: InvokerType? + public let pauseDuration: Int64? + public let sourceCharPosition: Int64? + public let sourceFunctionName: String? + public let sourceUrl: String? + public let startTime: Double? + public let windowAttribution: String? + public enum InvokerType: String, Codable + case userCallback = "user-callback" + case eventListener = "event-listener" + case resolvePromise = "resolve-promise" + case rejectPromise = "reject-promise" + case classicScript = "classic-script" + case moduleScript = "module-script" public struct Session: Codable public let hasReplay: Bool? public let id: String @@ -1309,6 +1337,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel public let forwardConsoleLogs: ForwardConsoleLogs? public let forwardErrorsToLogs: Bool? public let forwardReports: ForwardReports? + public var imagePrivacyLevel: String? public var initializationType: String? public var mobileVitalsUpdatePeriod: Int64? public internal(set) var plugins: [Plugins]? @@ -1327,6 +1356,8 @@ public struct TelemetryConfigurationEvent: RUMDataModel public let telemetryConfigurationSampleRate: Int64? public let telemetrySampleRate: Int64? public let telemetryUsageSampleRate: Int64? + public var textAndInputPrivacyLevel: String? + public var touchPrivacyLevel: String? public var traceContextInjection: TraceContextInjection? public let traceSampleRate: Int64? public var tracerApi: String? @@ -1400,6 +1431,92 @@ public struct TelemetryConfigurationEvent: RUMDataModel [?] extension TelemetryConfigurationEvent.Telemetry.Configuration.Plugins public func encode(to encoder: Encoder) throws public init(from decoder: Decoder) throws +public struct TelemetryUsageEvent: RUMDataModel + public let dd: DD + public let action: Action? + public let application: Application? + public let date: Int64 + public let experimentalFeatures: [String]? + public let service: String + public let session: Session? + public let source: Source + public internal(set) var telemetry: Telemetry + public let type: String = "telemetry" + public let version: String + public let view: View? + public struct DD: Codable + public let formatVersion: Int64 = 2 + public struct Action: Codable + public let id: String + public struct Application: Codable + public let id: String + public struct Session: Codable + public let id: String + public enum Source: String, Codable + case android = "android" + case ios = "ios" + case browser = "browser" + case flutter = "flutter" + case reactNative = "react-native" + case unity = "unity" + case kotlinMultiplatform = "kotlin-multiplatform" + public struct Telemetry: Codable + public let device: RUMTelemetryDevice? + public let os: RUMTelemetryOperatingSystem? + public let type: String = "usage" + public let usage: Usage + public internal(set) var telemetryInfo: [String: Encodable] + public enum Usage: Codable + case telemetryCommonFeaturesUsage(value: TelemetryCommonFeaturesUsage) + case telemetryMobileFeaturesUsage(value: TelemetryMobileFeaturesUsage) + public func encode(to encoder: Encoder) throws + public init(from decoder: Decoder) throws + public enum TelemetryCommonFeaturesUsage: Codable + case setTrackingConsent(value: SetTrackingConsent) + case stopSession(value: StopSession) + case startView(value: StartView) + case addAction(value: AddAction) + case addError(value: AddError) + case setGlobalContext(value: SetGlobalContext) + case setUser(value: SetUser) + case addFeatureFlagEvaluation(value: AddFeatureFlagEvaluation) + public func encode(to encoder: Encoder) throws + public init(from decoder: Decoder) throws + public struct SetTrackingConsent: Codable + public let feature: String = "set-tracking-consent" + public let trackingConsent: TrackingConsent + public enum TrackingConsent: String, Codable + case granted = "granted" + case notGranted = "not-granted" + case pending = "pending" + public struct StopSession: Codable + public let feature: String = "stop-session" + public struct StartView: Codable + public let feature: String = "start-view" + public struct AddAction: Codable + public let feature: String = "add-action" + public struct AddError: Codable + public let feature: String = "add-error" + public struct SetGlobalContext: Codable + public let feature: String = "set-global-context" + public struct SetUser: Codable + public let feature: String = "set-user" + public struct AddFeatureFlagEvaluation: Codable + public let feature: String = "add-feature-flag-evaluation" + public enum TelemetryMobileFeaturesUsage: Codable + case addViewLoadingTime(value: AddViewLoadingTime) + public func encode(to encoder: Encoder) throws + public init(from decoder: Decoder) throws + public struct AddViewLoadingTime: Codable + public let feature: String = "addViewLoadingTime" + public let noActiveView: Bool + public let noView: Bool + public let overwritten: Bool + public struct View: Codable + public let id: String +[?] extension TelemetryUsageEvent.Telemetry + public func encode(to encoder: Encoder) throws + public init(from decoder: Decoder) throws public enum RUMSessionPrecondition: String, Codable case userAppLaunch = "user_app_launch" case inactivityTimeout = "inactivity_timeout" @@ -1631,6 +1748,7 @@ public protocol RUMMonitorProtocol: AnyObject func stopView(viewController: UIViewController,attributes: [AttributeKey: AttributeValue]) func startView(key: String,name: String?,attributes: [AttributeKey: AttributeValue]) func stopView(key: String,attributes: [AttributeKey: AttributeValue]) + func addViewLoadingTime(overwrite: Bool) func addTiming(name: String) func addError(message: String,type: String?,stack: String?,source: RUMErrorSource,attributes: [AttributeKey: AttributeValue],file: StaticString?,line: UInt?) func addError(error: Error,source: RUMErrorSource,attributes: [AttributeKey: AttributeValue]) @@ -1704,6 +1822,7 @@ public struct SRSegment: SRDataModel case ios = "ios" case flutter = "flutter" case reactNative = "react-native" + case kotlinMultiplatform = "kotlin-multiplatform" public struct View: Codable public let id: String public struct SRShapeBorder: Codable, Hashable @@ -1986,14 +2105,16 @@ public class SessionReplayWireframesBuilder public func hash(into hasher: inout Hasher) public protocol SessionReplayTextObfuscating func mask(text: String) -> String -public extension PrivacyLevel +public extension TextAndInputPrivacyLevel var sensitiveTextObfuscator: SessionReplayTextObfuscating var inputAndOptionTextObfuscator: SessionReplayTextObfuscating var staticTextObfuscator: SessionReplayTextObfuscating var hintTextObfuscator: SessionReplayTextObfuscating public class Recorder: Recording public struct Context: Equatable - public let privacy: SessionReplayPrivacyLevel + public let textAndInputPrivacy: TextAndInputPrivacyLevel + public let imagePrivacy: ImagePrivacyLevel + public let touchPrivacy: TouchPrivacyLevel deinit public typealias NodeID = Int64 public final class NodeIDGenerator @@ -2040,15 +2161,32 @@ public struct SessionReplaySpecificElement: SessionReplayNodeSemantics public init(subtreeStrategy: SessionReplayNodeSubtreeStrategy,nodes: [SessionReplayNode]) public final class DDSessionReplay: NSObject public static func enable(with configuration: DDSessionReplayConfiguration) + public static func startRecording() + public static func stopRecording() public final class DDSessionReplayConfiguration: NSObject @objc public var replaySampleRate: Float @objc public var defaultPrivacyLevel: DDSessionReplayConfigurationPrivacyLevel + @objc public var textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel + @objc public var imagePrivacyLevel: DDImagePrivacyLevel + @objc public var touchPrivacyLevel: DDTouchPrivacyLevel @objc public var customEndpoint: URL? + public required init(replaySampleRate: Float,textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel,imagePrivacyLevel: DDImagePrivacyLevel,touchPrivacyLevel: DDTouchPrivacyLevel) public required init(replaySampleRate: Float) public enum DDSessionReplayConfigurationPrivacyLevel: Int case allow case mask case maskUserInput +public enum DDTextAndInputPrivacyLevel: Int + case maskSensitiveInputs + case maskAllInputs + case maskAll +public enum DDImagePrivacyLevel: Int + case maskNonBundledOnly + case maskAll + case maskNone +public enum DDTouchPrivacyLevel: Int + case show + case hide public enum SessionReplay public static func enable(with configuration: SessionReplay.Configuration,in core: DatadogCoreProtocol = CoreRegistry.default) public static func startRecording(in core: DatadogCoreProtocol = CoreRegistry.default) @@ -2056,8 +2194,12 @@ public enum SessionReplay [?] extension SessionReplay public struct Configuration public var replaySampleRate: Float - public var defaultPrivacyLevel: SessionReplayPrivacyLevel + public var defaultPrivacyLevel: SessionReplayPrivacyLevel = .mask + public var textAndInputPrivacyLevel: TextAndInputPrivacyLevel + public var imagePrivacyLevel: ImagePrivacyLevel + public var touchPrivacyLevel: TouchPrivacyLevel public var startRecordingImmediately: Bool public var customEndpoint: URL? + public init(replaySampleRate: Float,textAndInputPrivacyLevel: TextAndInputPrivacyLevel,imagePrivacyLevel: ImagePrivacyLevel,touchPrivacyLevel: TouchPrivacyLevel,startRecordingImmediately: Bool = true,customEndpoint: URL? = nil) public init(replaySampleRate: Float,defaultPrivacyLevel: SessionReplayPrivacyLevel = .mask,startRecordingImmediately: Bool = true,customEndpoint: URL? = nil) public mutating func setAdditionalNodeRecorders(_ additionalNodeRecorders: [SessionReplayNodeRecorder]) From 3160714327269efbe10606f97cdac49eda069264 Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov Date: Mon, 30 Sep 2024 10:46:31 +0200 Subject: [PATCH 04/43] Add KMP to the Segment.Source random value mock --- DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift b/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift index 855bb9a3cf..54642404ce 100644 --- a/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/SRDataModelsMocks.swift @@ -1124,7 +1124,8 @@ extension SRSegment.Source: AnyMockable, RandomMockable { .android, .ios, .flutter, - .reactNative + .reactNative, + .kotlinMultiplatform ].randomElement()! } } From 62f35207f48736acbbaf4095a5a34bc9c59386be Mon Sep 17 00:00:00 2001 From: Maciej Burda Date: Thu, 5 Sep 2024 14:17:34 +0100 Subject: [PATCH 05/43] Add background upload configuration info to batch deleted telemetry --- DatadogCore/Sources/Core/DatadogCore.swift | 1 + .../Sources/Core/Storage/FeatureStorage.swift | 7 +++++-- .../Core/Storage/FilesOrchestrator.swift | 4 +++- .../Sources/SDKMetrics/BatchMetrics.swift | 2 +- .../Tests/Datadog/Core/FeatureTests.swift | 1 + .../FilesOrchestrator+MetricsTests.swift | 3 ++- .../Persistence/Writing/FileWriterTests.swift | 21 ++++++++++++++++--- .../Tests/Telemetry/TelemetryTests.swift | 3 ++- 8 files changed, 33 insertions(+), 9 deletions(-) diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index e798504e23..e04d7e2450 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -255,6 +255,7 @@ extension DatadogCore: DatadogCoreProtocol { dateProvider: dateProvider, performance: performancePreset, encryption: encryption, + backgroundTasksEnabled: backgroundTasksEnabled, telemetry: telemetry ) diff --git a/DatadogCore/Sources/Core/Storage/FeatureStorage.swift b/DatadogCore/Sources/Core/Storage/FeatureStorage.swift index ee30072024..c2839e1bdc 100644 --- a/DatadogCore/Sources/Core/Storage/FeatureStorage.swift +++ b/DatadogCore/Sources/Core/Storage/FeatureStorage.swift @@ -116,6 +116,7 @@ extension FeatureStorage { dateProvider: DateProvider, performance: PerformancePreset, encryption: DataEncryption?, + backgroundTasksEnabled: Bool, telemetry: Telemetry ) { let trackName = BatchMetric.trackValue(for: featureName) @@ -133,7 +134,8 @@ extension FeatureStorage { return FilesOrchestrator.MetricsData( trackName: trackName, consentLabel: BatchMetric.consentGrantedValue, - uploaderPerformance: performance + uploaderPerformance: performance, + backgroundTasksEnabled: backgroundTasksEnabled ) } ) @@ -146,7 +148,8 @@ extension FeatureStorage { return FilesOrchestrator.MetricsData( trackName: trackName, consentLabel: BatchMetric.consentPendingValue, - uploaderPerformance: performance + uploaderPerformance: performance, + backgroundTasksEnabled: backgroundTasksEnabled ) } ) diff --git a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift index d0851d2eeb..95b55e4b31 100644 --- a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift +++ b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift @@ -48,6 +48,8 @@ internal class FilesOrchestrator: FilesOrchestratorType { let consentLabel: String /// The preset for uploader performance in this feature to include in metric. let uploaderPerformance: UploadPerformancePreset + /// The present configuration of background upload in this feature to include in metric. + let backgroundTasksEnabled: Bool } /// An extra information to include in metrics or `nil` if metrics should not be reported for this orchestrator. @@ -249,7 +251,7 @@ internal class FilesOrchestrator: FilesOrchestratorType { BatchDeletedMetric.uploaderWindowKey: performance.uploaderWindow.toMilliseconds, BatchDeletedMetric.batchAgeKey: batchAge.toMilliseconds, BatchDeletedMetric.batchRemovalReasonKey: deletionReason.toString(), - BatchDeletedMetric.inBackgroundKey: false + BatchDeletedMetric.inBackgroundKey: metricsData.backgroundTasksEnabled ], sampleRate: BatchDeletedMetric.sampleRate ) diff --git a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift index 0f91d21e0e..6660c159c3 100644 --- a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift +++ b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift @@ -54,7 +54,7 @@ internal enum BatchDeletedMetric { static let batchAgeKey = "batch_age" /// The reason of batch deletion. static let batchRemovalReasonKey = "batch_removal_reason" - /// If the batch was deleted in the background. + /// If the background upload was enabled. static let inBackgroundKey = "in_background" /// Allowed values for `batchRemovalReasonKey`. diff --git a/DatadogCore/Tests/Datadog/Core/FeatureTests.swift b/DatadogCore/Tests/Datadog/Core/FeatureTests.swift index b659feb597..8e7b3e7c71 100644 --- a/DatadogCore/Tests/Datadog/Core/FeatureTests.swift +++ b/DatadogCore/Tests/Datadog/Core/FeatureTests.swift @@ -23,6 +23,7 @@ class FeatureStorageTests: XCTestCase { dateProvider: RelativeDateProvider(advancingBySeconds: 0.01), performance: .mockRandom(), encryption: nil, + backgroundTasksEnabled: .mockRandom(), telemetry: NOPTelemetry() ) temporaryFeatureDirectories.create() diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift index c6f0e44b09..6e6e052cb0 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift @@ -38,7 +38,8 @@ class FilesOrchestrator_MetricsTests: XCTestCase { metricsData: FilesOrchestrator.MetricsData( trackName: "track name", consentLabel: "consent value", - uploaderPerformance: upload + uploaderPerformance: upload, + backgroundTasksEnabled: .mockAny() ) ) } diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/Writing/FileWriterTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/Writing/FileWriterTests.swift index 2483cbd254..6c6dacf7a5 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/Writing/FileWriterTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/Writing/FileWriterTests.swift @@ -150,7 +150,12 @@ class FileWriterTests: XCTestCase { ), dateProvider: SystemDateProvider(), telemetry: NOPTelemetry(), - metricsData: .init(trackName: "rum", consentLabel: .mockAny(), uploaderPerformance: UploadPerformanceMock.noOp) + metricsData: .init( + trackName: "rum", + consentLabel: .mockAny(), + uploaderPerformance: UploadPerformanceMock.noOp, + backgroundTasksEnabled: .mockAny() + ) ), encryption: nil, telemetry: NOPTelemetry() @@ -183,7 +188,12 @@ class FileWriterTests: XCTestCase { performance: PerformancePreset.mockAny(), dateProvider: SystemDateProvider(), telemetry: NOPTelemetry(), - metricsData: .init(trackName: "rum", consentLabel: .mockAny(), uploaderPerformance: UploadPerformanceMock.noOp) + metricsData: .init( + trackName: "rum", + consentLabel: .mockAny(), + uploaderPerformance: UploadPerformanceMock.noOp, + backgroundTasksEnabled: .mockAny() + ) ), encryption: nil, telemetry: NOPTelemetry() @@ -205,7 +215,12 @@ class FileWriterTests: XCTestCase { performance: PerformancePreset.mockAny(), dateProvider: SystemDateProvider(), telemetry: NOPTelemetry(), - metricsData: .init(trackName: "rum", consentLabel: .mockAny(), uploaderPerformance: UploadPerformanceMock.noOp) + metricsData: .init( + trackName: "rum", + consentLabel: .mockAny(), + uploaderPerformance: UploadPerformanceMock.noOp, + backgroundTasksEnabled: .mockAny() + ) ), encryption: nil, telemetry: NOPTelemetry() diff --git a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift index d10b51b4c1..af90e100e7 100644 --- a/DatadogInternal/Tests/Telemetry/TelemetryTests.swift +++ b/DatadogInternal/Tests/Telemetry/TelemetryTests.swift @@ -109,12 +109,13 @@ class TelemetryTests: XCTestCase { func testSendingConfigurationTelemetry() throws { // When - telemetry.configuration(batchSize: 123, batchUploadFrequency: 456) // only some values + telemetry.configuration(backgroundTasksEnabled: true, batchSize: 123, batchUploadFrequency: 456) // only some values // Then let configuration = try XCTUnwrap(telemetry.messages.firstConfiguration()) XCTAssertEqual(configuration.batchSize, 123) XCTAssertEqual(configuration.batchUploadFrequency, 456) + XCTAssertEqual(configuration.backgroundTasksEnabled, true) } // MARK: - Metric Telemetry From dcae374272a9b31556ecf0717b4135523f0b5c03 Mon Sep 17 00:00:00 2001 From: Maciej Burda Date: Thu, 5 Sep 2024 14:25:16 +0100 Subject: [PATCH 06/43] Lint sources --- DatadogCore/Sources/Core/Storage/FeatureStorage.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DatadogCore/Sources/Core/Storage/FeatureStorage.swift b/DatadogCore/Sources/Core/Storage/FeatureStorage.swift index c2839e1bdc..31eb3f13d5 100644 --- a/DatadogCore/Sources/Core/Storage/FeatureStorage.swift +++ b/DatadogCore/Sources/Core/Storage/FeatureStorage.swift @@ -134,7 +134,7 @@ extension FeatureStorage { return FilesOrchestrator.MetricsData( trackName: trackName, consentLabel: BatchMetric.consentGrantedValue, - uploaderPerformance: performance, + uploaderPerformance: performance, backgroundTasksEnabled: backgroundTasksEnabled ) } @@ -148,7 +148,7 @@ extension FeatureStorage { return FilesOrchestrator.MetricsData( trackName: trackName, consentLabel: BatchMetric.consentPendingValue, - uploaderPerformance: performance, + uploaderPerformance: performance, backgroundTasksEnabled: backgroundTasksEnabled ) } From 17ef89bf0cdebabf33d70890e9825ad10d88cf8d Mon Sep 17 00:00:00 2001 From: Maciej Burda Date: Thu, 3 Oct 2024 10:22:00 +0100 Subject: [PATCH 07/43] Use new field --- DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift | 3 ++- DatadogCore/Sources/SDKMetrics/BatchMetrics.swift | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift index 95b55e4b31..c85f1e29a8 100644 --- a/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift +++ b/DatadogCore/Sources/Core/Storage/FilesOrchestrator.swift @@ -251,7 +251,8 @@ internal class FilesOrchestrator: FilesOrchestratorType { BatchDeletedMetric.uploaderWindowKey: performance.uploaderWindow.toMilliseconds, BatchDeletedMetric.batchAgeKey: batchAge.toMilliseconds, BatchDeletedMetric.batchRemovalReasonKey: deletionReason.toString(), - BatchDeletedMetric.inBackgroundKey: metricsData.backgroundTasksEnabled + BatchDeletedMetric.inBackgroundKey: false, + BatchDeletedMetric.backgroundTasksEnabled: metricsData.backgroundTasksEnabled ], sampleRate: BatchDeletedMetric.sampleRate ) diff --git a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift index 6660c159c3..b7deb4ce21 100644 --- a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift +++ b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift @@ -54,8 +54,10 @@ internal enum BatchDeletedMetric { static let batchAgeKey = "batch_age" /// The reason of batch deletion. static let batchRemovalReasonKey = "batch_removal_reason" - /// If the background upload was enabled. + /// If the batch was deleted in the background. static let inBackgroundKey = "in_background" + /// If the background tasks were enabled. + static let backgroundTasksEnabled = "background_tasks_enabled" /// Allowed values for `batchRemovalReasonKey`. enum RemovalReason { From ed023e3c7ecf1fee05ad6060b7c673857e7fe395 Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov Date: Thu, 3 Oct 2024 12:44:15 +0200 Subject: [PATCH 08/43] RUM-6395: Add internal logging/telemetry APIs to ObjC --- CHANGELOG.md | 3 + Datadog/Datadog.xcodeproj/project.pbxproj | 18 ++++ .../DatadogObjc/DDInternalLoggerTests.swift | 89 +++++++++++++++++++ .../ObjcAPITests/DDInternalLogger+apiTests.m | 25 ++++++ .../Sources/DDInternalLogger+objc.swift | 42 +++++++++ api-surface-objc | 9 ++ 6 files changed, 186 insertions(+) create mode 100644 DatadogCore/Tests/DatadogObjc/DDInternalLoggerTests.swift create mode 100644 DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDInternalLogger+apiTests.m create mode 100644 DatadogObjc/Sources/DDInternalLogger+objc.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a7aecbdb..764fd57a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- [IMPROVEMENT] Add ObjC API for the internal logging/telemetry. See [#2073][] + # 2.18.0 / 25-09-2024 - [IMPROVEMENT] Add overwrite required (breaking) param to addViewLoadingTime & usage telemetry. See [#2040][] - [FEATURE] Prevent "show password" features from revealing sensitive texts in Session Replay. See [#2050][] @@ -774,6 +776,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2043]: https://github.com/DataDog/dd-sdk-ios/pull/2043 [#2040]: https://github.com/DataDog/dd-sdk-ios/pull/2040 [#2050]: https://github.com/DataDog/dd-sdk-ios/pull/2050 +[#2073]: https://github.com/DataDog/dd-sdk-ios/pull/2073 [@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 260354e8cf..638d7a4191 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1705,6 +1705,12 @@ E2AA55E82C32C6D9002FEF28 /* ApplicationNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2AA55E62C32C6D9002FEF28 /* ApplicationNotifications.swift */; }; E2AA55EA2C32C76A002FEF28 /* WatchKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2AA55E92C32C76A002FEF28 /* WatchKitExtensions.swift */; }; E2AA55EC2C32C78B002FEF28 /* WatchKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2AA55E92C32C76A002FEF28 /* WatchKitExtensions.swift */; }; + F603F1262CAE9F760088E6B7 /* DDInternalLogger+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F603F1252CAE9F760088E6B7 /* DDInternalLogger+objc.swift */; }; + F603F1272CAE9F760088E6B7 /* DDInternalLogger+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F603F1252CAE9F760088E6B7 /* DDInternalLogger+objc.swift */; }; + F603F12B2CAEA4FA0088E6B7 /* DDInternalLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */; }; + F603F12C2CAEA7180088E6B7 /* DDInternalLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */; }; + F603F1302CAEA7620088E6B7 /* DDInternalLogger+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F603F12D2CAEA7590088E6B7 /* DDInternalLogger+apiTests.m */; }; + F603F1312CAEA7630088E6B7 /* DDInternalLogger+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F603F12D2CAEA7590088E6B7 /* DDInternalLogger+apiTests.m */; }; F6E106542C75E0D000716DC6 /* LogsDataModels+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E106532C75E0D000716DC6 /* LogsDataModels+objc.swift */; }; F6E106552C75E0D000716DC6 /* LogsDataModels+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E106532C75E0D000716DC6 /* LogsDataModels+objc.swift */; }; /* End PBXBuildFile section */ @@ -3067,6 +3073,9 @@ E1D5AEA624B4D45A007F194B /* Versioning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Versioning.swift; sourceTree = ""; }; E2AA55E62C32C6D9002FEF28 /* ApplicationNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationNotifications.swift; sourceTree = ""; }; E2AA55E92C32C76A002FEF28 /* WatchKitExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchKitExtensions.swift; sourceTree = ""; }; + F603F1252CAE9F760088E6B7 /* DDInternalLogger+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DDInternalLogger+objc.swift"; sourceTree = ""; }; + F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDInternalLoggerTests.swift; sourceTree = ""; }; + F603F12D2CAEA7590088E6B7 /* DDInternalLogger+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDInternalLogger+apiTests.m"; sourceTree = ""; }; F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMUserActionsPredicate.swift; sourceTree = ""; }; F6E106532C75E0D000716DC6 /* LogsDataModels+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogsDataModels+objc.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -4281,6 +4290,7 @@ 6111C58025C0080C00F5C4A2 /* RUM */, 6132BF4024A38D0600D7BD17 /* OpenTracing */, D2A434A72A8E3FFB0028E329 /* SessionReplay */, + F603F1252CAE9F760088E6B7 /* DDInternalLogger+objc.swift */, ); name = DatadogObjc; path = ../DatadogObjc/Sources; @@ -4319,6 +4329,7 @@ 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, + F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); path = DatadogObjc; sourceTree = ""; @@ -5240,6 +5251,7 @@ 3C1890132ABDE99200CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m */, A795069D2B974CAA00AC4814 /* DDSessionReplay+apiTests.m */, 6174D6052BFB9D5500EC7469 /* DDWebViewTracking+apiTests.m */, + F603F12D2CAEA7590088E6B7 /* DDInternalLogger+apiTests.m */, ); path = ObjcAPITests; sourceTree = ""; @@ -8189,6 +8201,7 @@ 61133C572423990D00786299 /* FileReaderTests.swift in Sources */, D2A1EE38287EEB7400D28DFB /* NetworkConnectionInfoPublisherTests.swift in Sources */, D24C9C7129A7D57A002057CF /* DirectoriesMock.swift in Sources */, + F603F1302CAEA7620088E6B7 /* DDInternalLogger+apiTests.m in Sources */, D22743E329DEB90B001A7EF9 /* RUMDebuggingTests.swift in Sources */, 614798992A459B2E0095CB02 /* DDTraceConfigurationTests.swift in Sources */, 61DCC84A2C05D4D600CB59E5 /* RUMSessionEndedMetricIntegrationTests.swift in Sources */, @@ -8282,6 +8295,7 @@ D2553807288AA84F00727FAD /* UploadMock.swift in Sources */, D28F836C29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift in Sources */, D22743E629DEB953001A7EF9 /* UIApplicationSwizzlerTests.swift in Sources */, + F603F12B2CAEA4FA0088E6B7 /* DDInternalLoggerTests.swift in Sources */, D20FD9D62ACC0934004D3569 /* WebLogIntegrationTests.swift in Sources */, D21C26D128A64599005DD405 /* MessageBusTests.swift in Sources */, ); @@ -8298,6 +8312,7 @@ A728ADAB2934EA2100397996 /* W3CHTTPHeadersWriter+objc.swift in Sources */, A79B0F66292BD7CA008742B3 /* B3HTTPHeadersWriter+objc.swift in Sources */, 3CCCA5C42ABAF0F80029D7BD /* DDURLSessionInstrumentation+objc.swift in Sources */, + F603F1262CAE9F760088E6B7 /* DDInternalLogger+objc.swift in Sources */, 61133C0E2423983800786299 /* Datadog+objc.swift in Sources */, 3CA852642BF2148200B52CBA /* TraceContextInjection+objc.swift in Sources */, 61133C102423983800786299 /* Logs+objc.swift in Sources */, @@ -9492,6 +9507,8 @@ 6136CB4B2A69C29C00AC265D /* FilesOrchestrator+MetricsTests.swift in Sources */, D25085112976E30000E931C3 /* DatadogRemoteFeatureMock.swift in Sources */, A7CA21842BEBB2E200732571 /* ExtensionBackgroundTaskCoordinatorTests.swift in Sources */, + F603F1312CAEA7630088E6B7 /* DDInternalLogger+apiTests.m in Sources */, + F603F12C2CAEA7180088E6B7 /* DDInternalLoggerTests.swift in Sources */, D2CB6F3327C520D400A62B57 /* FilesOrchestratorTests.swift in Sources */, D2FB1258292E0F10005B13F8 /* TrackingConsentPublisherTests.swift in Sources */, D2CB6F3B27C520D400A62B57 /* NSURLSessionBridge.m in Sources */, @@ -9572,6 +9589,7 @@ files = ( F6E106552C75E0D000716DC6 /* LogsDataModels+objc.swift in Sources */, D2CB6F9927C5217A00A62B57 /* Casting.swift in Sources */, + F603F1272CAE9F760088E6B7 /* DDInternalLogger+objc.swift in Sources */, D2CB6F9A27C5217A00A62B57 /* RUMDataModels+objc.swift in Sources */, D2CB6F9B27C5217A00A62B57 /* DDSpanContext+objc.swift in Sources */, D2CB6F9C27C5217A00A62B57 /* OTTracer+objc.swift in Sources */, diff --git a/DatadogCore/Tests/DatadogObjc/DDInternalLoggerTests.swift b/DatadogCore/Tests/DatadogObjc/DDInternalLoggerTests.swift new file mode 100644 index 0000000000..ffb2074a5d --- /dev/null +++ b/DatadogCore/Tests/DatadogObjc/DDInternalLoggerTests.swift @@ -0,0 +1,89 @@ +/* +* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +* This product includes software developed at Datadog (https://www.datadoghq.com/). +* Copyright 2019-Present Datadog, Inc. +*/ + +import XCTest +import TestUtilities + +@testable import DatadogInternal +@testable import DatadogCore +@testable import DatadogObjc + +class DDInternalLoggerTests: XCTestCase { + let telemetry = TelemetryReceiverMock() + + private var core: PassthroughCoreMock! // swiftlint:disable:this implicitly_unwrapped_optional + + override func setUp() { + super.setUp() + core = PassthroughCoreMock(messageReceiver: telemetry) + } + + override func tearDown() { + core = nil + super.tearDown() + } + + func testObjcTelemetryDebugCallsTelemetryDebug() throws { + CoreRegistry.register(default: core) + defer { CoreRegistry.unregisterDefault() } + + // Given + let id: String = .mockAny() + let message: String = .mockAny() + + // When + DDInternalLogger.telemetryDebug(id: id, message: message) + + // Then + XCTAssertEqual(telemetry.messages.count, 1) + let debug = try XCTUnwrap(telemetry.messages.first?.asDebug, "A debug should be send to `telemetry`.") + XCTAssertEqual(debug.id, id) + XCTAssertEqual(debug.message, message) + } + + func testObjcTelemetryErrorCallsTelemetryError() throws { + CoreRegistry.register(default: core) + defer { CoreRegistry.unregisterDefault() } + + // Given + let id: String = .mockAny() + let message: String = .mockAny() + let stack: String = .mockAny() + let kind: String = .mockAny() + + // When + DDInternalLogger.telemetryError(id: id, message: message, kind: kind, stack: stack) + + // Then + XCTAssertEqual(telemetry.messages.count, 1) + + let error = try XCTUnwrap(telemetry.messages.first?.asError, "An error should be send to `telemetry`.") + XCTAssertEqual(error.id, id) + XCTAssertEqual(error.message, message) + XCTAssertEqual(error.kind, kind) + XCTAssertEqual(error.stack, stack) + } + + func testWhenTelemetryIsSentThroughObjc_thenItForwardsToDDTelemetry() throws { + CoreRegistry.register(default: core) + defer { CoreRegistry.unregisterDefault() } + + // When + let randomDebugMessage: String = .mockRandom() + let randomErrorMessage: String = .mockRandom() + DDInternalLogger.telemetryDebug(id: .mockAny(), message: randomDebugMessage) + DDInternalLogger.telemetryError(id: .mockAny(), message: randomErrorMessage, kind: .mockAny(), stack: .mockAny()) + + // Then + XCTAssertEqual(telemetry.messages.count, 2) + + let debug = try XCTUnwrap(telemetry.messages.first?.asDebug, "A debug should be send to `telemetry`.") + XCTAssertEqual(debug.message, randomDebugMessage) + + let error = try XCTUnwrap(telemetry.messages.last?.asError, "An error should be send to `telemetry`.") + XCTAssertEqual(error.message, randomErrorMessage) + } +} diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDInternalLogger+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDInternalLogger+apiTests.m new file mode 100644 index 0000000000..dd19b37dcd --- /dev/null +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDInternalLogger+apiTests.m @@ -0,0 +1,25 @@ +/* +* 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 +@import DatadogObjc; + +@interface DDInternalLogger_apiTests : XCTestCase +@end + +/* + * `DDInternalLogger` APIs smoke tests - only check if the interface is available to Objc. + */ +@implementation DDInternalLogger_apiTests + +- (void)testDDInternalLogger { + + [DDInternalLogger consolePrint:@"" :DDCoreLoggerLevelWarn]; + [DDInternalLogger telemetryDebugWithId:@"" message:@""]; + [DDInternalLogger telemetryErrorWithId:@"" message:@"" kind:@"" stack:@""]; +} + +@end diff --git a/DatadogObjc/Sources/DDInternalLogger+objc.swift b/DatadogObjc/Sources/DDInternalLogger+objc.swift new file mode 100644 index 0000000000..17125114c5 --- /dev/null +++ b/DatadogObjc/Sources/DDInternalLogger+objc.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 Foundation +import DatadogInternal +import DatadogCore + +@objc +public class DDInternalLogger: NSObject { + /// Function printing `String` content to console. Intended to be used only by SDK components. + @objc + public static func consolePrint(_ message: String, _ level: DDCoreLoggerLevel) { + let coreLoggerLevel: CoreLoggerLevel = switch level { + case .debug: .debug + case .warn: .warn + case .error: .error + case .critical: .critical + } + DatadogInternal.consolePrint(message, coreLoggerLevel) + } + + @objc + public static func telemetryDebug(id: String, message: String) { + Datadog._internal.telemetry.debug(id: id, message: message) + } + + @objc + public static func telemetryError(id: String, message: String, kind: String?, stack: String?) { + Datadog._internal.telemetry.error(id: id, message: message, kind: kind, stack: stack) + } +} + +@objc +public enum DDCoreLoggerLevel: Int { + case debug + case warn + case error + case critical +} diff --git a/api-surface-objc b/api-surface-objc index fbeea6cb9c..540786f163 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -2,6 +2,15 @@ # API surface for DatadogObjc: # ---------------------------------- +public class DDInternalLogger: NSObject + public static func consolePrint(_ message: String, _ level: DDCoreLoggerLevel) + public static func telemetryDebug(id: String, message: String) + public static func telemetryError(id: String, message: String, kind: String?, stack: String?) +public enum DDCoreLoggerLevel: Int + case debug + case warn + case error + case critical open class DDNSURLSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate override public init() public init(additionalFirstPartyHostsWithHeaderTypes: [String: Set]) From f23be19d90e53002c974c229f1d8d8b6b7962811 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Fri, 4 Oct 2024 11:17:17 +0200 Subject: [PATCH 09/43] RUM-6386 Add default value to replaySampleRate --- .../WebRecordIntegrationTests.swift | 52 +++++++++++++++++++ .../Sources/SessionReplayConfiguration.swift | 6 +-- .../SessionReplayConfigurationTests.swift | 41 +++++++++++++-- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift index 80619be8f5..228313ff6b 100644 --- a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift +++ b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift @@ -91,6 +91,58 @@ class WebRecordIntegrationTests: XCTestCase { "slotId": expectedSlotID ]) } + + func testWebRecordIntegrationWithNewSessionReplayConfigurationAPI() throws { + // Given + let randomApplicationID: String = .mockRandom() + let randomUUID: UUID = .mockRandom() + let randomBrowserViewID: UUID = .mockRandom() + + SessionReplay.enable(with: SessionReplay.Configuration( + replaySampleRate: 100, + textAndInputPrivacyLevel: .mockRandom(), + imagePrivacyLevel: .mockRandom(), + touchPrivacyLevel: .mockRandom() + ), in: core) + RUM.enable(with: .mockWith(applicationID: randomApplicationID) { + $0.uuidGenerator = RUMUUIDGeneratorMock(uuid: randomUUID) + }, in: core) + + let body = """ + { + "eventType": "record", + "event": { + "timestamp" : \(1635932927012), + "type": 2 + }, + "view": { "id": "\(randomBrowserViewID.uuidString.lowercased())" } + } + """ + + // When + RUMMonitor.shared(in: core).startView(key: "web-view") + controller.send(body: body, from: webView) + controller.flush() + + // Then + let segments = try core.waitAndReturnEventsData(ofFeature: SessionReplayFeature.name) + .map { try SegmentJSON($0, source: .ios) } + let segment = try XCTUnwrap(segments.first) + + let expectedUUID = randomUUID.uuidString.lowercased() + let expectedSlotID = String(webView.hash) + + XCTAssertEqual(segment.applicationID, randomApplicationID) + XCTAssertEqual(segment.sessionID, expectedUUID) + XCTAssertEqual(segment.viewID, randomBrowserViewID.uuidString.lowercased()) + + let record = try XCTUnwrap(segment.records.first) + DDAssertDictionariesEqual(record, [ + "timestamp": 1_635_932_927_012 + 123.toInt64Milliseconds, + "type": 2, + "slotId": expectedSlotID + ]) + } } #endif diff --git a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift index b6f85be7ee..c66ad250b0 100644 --- a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift +++ b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift @@ -81,8 +81,8 @@ extension SessionReplay { /// - defaultImageRecordingLevel: Image recording privacy level. Default: `.maskAll`. /// - customEndpoint: Custom server url for sending replay data. Default: `nil`. - public init( - replaySampleRate: Float, + public init( // swiftlint:disable:this function_default_parameter_at_end + replaySampleRate: Float = 100, textAndInputPrivacyLevel: TextAndInputPrivacyLevel, imagePrivacyLevel: ImagePrivacyLevel, touchPrivacyLevel: TouchPrivacyLevel, @@ -105,7 +105,7 @@ extension SessionReplay { /// - customEndpoint: Custom server url for sending replay data. Default: `nil`. @available(*, deprecated, message: "This will be removed in future versions of the SDK. Use `init(replaySampleRate:textAndInputPrivacyLevel:imagePrivacyLevel:touchPrivacyLevel:)` instead.") public init( - replaySampleRate: Float, + replaySampleRate: Float = 100, defaultPrivacyLevel: SessionReplayPrivacyLevel = .mask, startRecordingImmediately: Bool = true, customEndpoint: URL? = nil diff --git a/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift b/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift index 14b35b0ea5..882408a54d 100644 --- a/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayConfigurationTests.swift @@ -12,15 +12,34 @@ import XCTest class SessionReplayConfigurationTests: XCTestCase { func testDefaultConfiguration() { - let random: Float = .mockRandom(min: 0, max: 100) + // When + let config = SessionReplay.Configuration() + + // Then + XCTAssertEqual(config.replaySampleRate, 100) + XCTAssertEqual(config.defaultPrivacyLevel, .mask) + XCTAssertEqual(config.textAndInputPrivacyLevel, .maskAll) + XCTAssertEqual(config.imagePrivacyLevel, .maskAll) + XCTAssertEqual(config.touchPrivacyLevel, .hide) + XCTAssertEqual(config.startRecordingImmediately, true) + XCTAssertNil(config.customEndpoint) + XCTAssertEqual(config._additionalNodeRecorders.count, 0) + } + func testDefaultConfigurationWithNewApi() { // When - let config = SessionReplay.Configuration(replaySampleRate: random) + let config = SessionReplay.Configuration( + textAndInputPrivacyLevel: .maskAll, + imagePrivacyLevel: .maskAll, + touchPrivacyLevel: .hide + ) // Then - XCTAssertEqual(config.replaySampleRate, random) + XCTAssertEqual(config.replaySampleRate, 100) XCTAssertEqual(config.defaultPrivacyLevel, .mask) + XCTAssertEqual(config.textAndInputPrivacyLevel, .maskAll) XCTAssertEqual(config.imagePrivacyLevel, .maskAll) + XCTAssertEqual(config.touchPrivacyLevel, .hide) XCTAssertEqual(config.startRecordingImmediately, true) XCTAssertNil(config.customEndpoint) XCTAssertEqual(config._additionalNodeRecorders.count, 0) @@ -38,5 +57,21 @@ class SessionReplayConfigurationTests: XCTestCase { XCTAssertEqual(config._additionalNodeRecorders.count, 1) XCTAssertEqual(config._additionalNodeRecorders[0].identifier, mockNodeRecorder.identifier) } + + func testConfigurationWithAdditionalNodeRecordersWithNewApi() { + let mockNodeRecorder = SessionReplayNodeRecorderMock() + + // When + var config = SessionReplay.Configuration( + textAndInputPrivacyLevel: .maskAll, + imagePrivacyLevel: .maskAll, + touchPrivacyLevel: .hide + ) + config.setAdditionalNodeRecorders([mockNodeRecorder]) + + // Then + XCTAssertEqual(config._additionalNodeRecorders.count, 1) + XCTAssertEqual(config._additionalNodeRecorders[0].identifier, mockNodeRecorder.identifier) + } } #endif From 4cd62cb980913791f1d82bf7d6a89b1318b27c80 Mon Sep 17 00:00:00 2001 From: Maciej Burda Date: Fri, 4 Oct 2024 11:20:36 +0100 Subject: [PATCH 10/43] Update bundle --- Gemfile.lock | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bd00d94a2a..ddd56f2c80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -69,16 +69,25 @@ GEM escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) + ffi (1.17.0) + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-aarch64-linux-musl) + ffi (1.17.0-arm-linux-gnu) + ffi (1.17.0-arm-linux-musl) ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86-linux-gnu) + ffi (1.17.0-x86-linux-musl) + ffi (1.17.0-x86_64-darwin) ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.0-x86_64-linux-musl) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) httpclient (2.8.3) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) json (2.7.2) - logger (1.6.0) + logger (1.6.1) minitest (5.25.1) molinillo (0.8.0) nanaimo (0.3.0) @@ -86,31 +95,36 @@ GEM netrc (0.11.0) nkf (0.2.0) public_suffix (4.0.7) - rexml (3.3.6) - strscan + rexml (3.3.8) ruby-macho (2.5.1) securerandom (0.3.1) - strscan (3.1.0) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.25.0) + xcodeproj (1.25.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) + rexml (>= 3.3.6, < 4.0) PLATFORMS - arm64-darwin-21 - universal-darwin-22 - universal-darwin-23 - x86_64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES cocoapods (= 1.15.2) BUNDLED WITH - 2.5.18 + 2.5.21 From ed6b824fbd0bd40c6cf121a54c96372033cde6af Mon Sep 17 00:00:00 2001 From: Maciej Burda Date: Fri, 4 Oct 2024 11:27:19 +0100 Subject: [PATCH 11/43] Fix tests --- .../Core/Persistence/FilesOrchestrator+MetricsTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift index 6e6e052cb0..d6a8d88b46 100644 --- a/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift +++ b/DatadogCore/Tests/Datadog/Core/Persistence/FilesOrchestrator+MetricsTests.swift @@ -69,6 +69,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { ], "uploader_window": storage.uploaderWindow.toMilliseconds, "in_background": false, + "background_tasks_enabled": false, "batch_age": expectedBatchAge.toMilliseconds, "batch_removal_reason": "intake-code-202", ]) @@ -99,6 +100,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { ], "uploader_window": storage.uploaderWindow.toMilliseconds, "in_background": false, + "background_tasks_enabled": false, "batch_age": (storage.maxFileAgeForRead + 1).toMilliseconds, "batch_removal_reason": "obsolete", ]) @@ -132,6 +134,7 @@ class FilesOrchestrator_MetricsTests: XCTestCase { ], "uploader_window": storage.uploaderWindow.toMilliseconds, "in_background": false, + "background_tasks_enabled": false, "batch_age": expectedBatchAge.toMilliseconds, "batch_removal_reason": "purged", ]) From 9e4cc5f95555d911166a83e115a9108a771004ee Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Thu, 12 Sep 2024 18:13:00 +0200 Subject: [PATCH 12/43] RUM-5650 Separate SPM build jobs for Swift 5.9 and 5.10 --- .gitlab-ci.yml | 58 +++++++++----------------------- tools/runner-setup.sh | 78 ++++++++++++++++--------------------------- 2 files changed, 44 insertions(+), 92 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 93c6784955..9205d662c5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -187,71 +187,42 @@ Smoke Tests (iOS): rules: - !reference [.test-pipeline-job, rules] - !reference [.release-pipeline-job, rules] - tags: - - macos:ventura - - specific:true variables: - XCODE: "15.2.0" - OS: "17.2" PLATFORM: "iOS Simulator" DEVICE: "iPhone 15 Pro" script: - - ./tools/runner-setup.sh --xcode "$XCODE" --iOS --os "$OS" --ssh # temporary, waiting for AMI + - ./tools/runner-setup.sh --xcode "$DEFAULT_XCODE" --ssh - make clean repo-setup ENV=ci - - make spm-build-ios - - make smoke-test-ios-all OS="$OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" + - make smoke-test-ios-all OS="$DEFAULT_IOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" Smoke Tests (tvOS): stage: smoke-test rules: - !reference [.test-pipeline-job, rules] - !reference [.release-pipeline-job, rules] - tags: - - macos:ventura - - specific:true variables: - XCODE: "15.2.0" - OS: "17.2" PLATFORM: "tvOS Simulator" DEVICE: "Apple TV" script: - - ./tools/runner-setup.sh --xcode "$XCODE" --tvOS --os "$OS" --ssh # temporary, waiting for AMI - - make clean repo-setup ENV=ci - - make spm-build-tvos - - make smoke-test-tvos-all OS="$OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" - -Smoke Tests (visionOS): - stage: smoke-test - rules: - - !reference [.test-pipeline-job, rules] - - !reference [.release-pipeline-job, rules] - tags: - - macos:ventura - - specific:true - variables: - XCODE: "15.2.0" - OS: "1.0" - script: - - ./tools/runner-setup.sh --xcode "$XCODE" --visionOS --os "$OS" # temporary, waiting for AMI + - ./tools/runner-setup.sh --xcode "$DEFAULT_XCODE" --ssh - make clean repo-setup ENV=ci - - make spm-build-visionos + - make smoke-test-tvos-all OS="$DEFAULT_IOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" -Smoke Tests (macOS): +SPM Build (Swift 5.10): stage: smoke-test rules: - !reference [.test-pipeline-job, rules] - !reference [.release-pipeline-job, rules] - tags: - - macos:ventura - - specific:true - variables: - XCODE: "15.2.0" script: - - ./tools/runner-setup.sh --xcode "$XCODE" # temporary, waiting for AMI + - ./tools/runner-setup.sh --xcode "$DEFAULT_XCODE" --iOS --tvOS --visionOS --watchOS - make clean repo-setup ENV=ci + - make spm-build-ios + - make spm-build-tvos + - make spm-build-visionos - make spm-build-macos + - make spm-build-watchos -Smoke Tests (watchOS): +SPM Build (Swift 5.9): stage: smoke-test rules: - !reference [.test-pipeline-job, rules] @@ -261,10 +232,13 @@ Smoke Tests (watchOS): - specific:true variables: XCODE: "15.2.0" - OS: "10.2" script: - - ./tools/runner-setup.sh --xcode "$XCODE" --watchOS --os "$OS" # temporary, waiting for AMI + - ./tools/runner-setup.sh --xcode "$XCODE" --iOS --tvOS --visionOS --watchOS - make clean repo-setup ENV=ci + - make spm-build-ios + - make spm-build-tvos + - make spm-build-visionos + - make spm-build-macos - make spm-build-watchos # ┌──────────────────────┐ diff --git a/tools/runner-setup.sh b/tools/runner-setup.sh index 3870ff0762..aa469b2456 100755 --- a/tools/runner-setup.sh +++ b/tools/runner-setup.sh @@ -2,32 +2,30 @@ # Usage: # $ ./tools/runner-setup.sh -h -# This script is for TEMPORARY. It supplements missing components on the runner. It will be removed once all configurations are integrated into the AMI. +# This script supplements missing components on the runner before they are included through the AMI. # Options: -# --xcode: Sets the Xcode version on the runner. -# --iOS: Flag that prepares the runner instance for iOS testing. Disabled by default. -# --tvOS: Flag that prepares the runner instance for tvOS testing. Disabled by default. -# --visionOS: Flag that prepares the runner instance for visionOS testing. Disabled by default. -# --watchOS: Flag that prepares the runner instance for watchOS testing. Disabled by default. -# --os: Sets the expected OS version for installed simulators when --iOS, --tvOS, --visionOS or --watchOS flag is set. Default: '17.4'. -# --ssh: Flag that adds ssh configuration for interacting with GitHub repositories. Disabled by default. -# --datadog-ci: Flag that installs 'datadog-ci' on the runner. Disabled by default. +# --xcode: Specify the Xcode version to activate. +# --iOS: Install the iOS platform with the latest simulator if not already installed. Default: disabled. +# --tvOS: Install the tvOS platform with the latest simulator if not already installed. Default: disabled. +# --visionOS: Install the visionOS platform with the latest simulator if not already installed. Default: disabled. +# --watchOS: Install the watchOS platform with the latest simulator if not already installed. Default: disabled. +# --ssh: Configure SSH for GitHub repository access. Default: disabled. +# --datadog-ci: Install 'datadog-ci' on the runner. Default: disabled. set -eo pipefail source ./tools/utils/echo-color.sh source ./tools/utils/argparse.sh source ./tools/secrets/get-secret.sh -set_description "This script is for TEMPORARY. It supplements missing components on the runner. It will be removed once all configurations are integrated into the AMI." -define_arg "xcode" "" "Sets the Xcode version on the runner." "string" "false" -define_arg "iOS" "false" "Flag that prepares the runner instance for iOS testing. Disabled by default." "store_true" -define_arg "tvOS" "false" "Flag that prepares the runner instance for tvOS testing. Disabled by default." "store_true" -define_arg "visionOS" "false" "Flag that prepares the runner instance for visionOS testing. Disabled by default." "store_true" -define_arg "watchOS" "false" "Flag that prepares the runner instance for watchOS testing. Disabled by default." "store_true" -define_arg "os" "17.4" "Sets the expected OS version for installed simulators when --iOS, --tvOS, --visionOS or --watchOS flag is set. Default: '17.4'." "string" "false" -define_arg "ssh" "false" "Flag that adds ssh configuration for interacting with GitHub repositories. Disabled by default." "store_true" -define_arg "datadog-ci" "false" "Flag that installs 'datadog-ci' on the runner. Disabled by default." "store_true" +set_description "This script supplements missing components on the runner before they are included through the AMI." +define_arg "xcode" "" "Specify the Xcode version to activate." "string" "false" +define_arg "iOS" "false" "Install the iOS platform with the latest simulator if not already installed. Default: disabled." "store_true" +define_arg "tvOS" "false" "Install the tvOS platform with the latest simulator if not already installed. Default: disabled." "store_true" +define_arg "visionOS" "false" "Install the visionOS platform with the latest simulator if not already installed. Default: disabled." "store_true" +define_arg "watchOS" "false" "Install the watchOS platform with the latest simulator if not already installed. Default: disabled." "store_true" +define_arg "ssh" "false" "Configure SSH for GitHub repository access. Default: disabled." "store_true" +define_arg "datadog-ci" "false" "Install 'datadog-ci' on the runner. Default: disabled." "store_true" check_for_help "$@" parse_args "$@" @@ -79,47 +77,27 @@ echo_succ "Using 'xcodebuild -version':" xcodebuild -version if [ "$iOS" = "true" ]; then - echo_subtitle "Supply iPhone Simulator runtime ($os)" - echo "Check current runner for any iPhone Simulator runtime supporting OS '$os':" - if ! xctrace list devices | grep "iPhone.*Simulator ($os)"; then - echo_warn "Found no iOS Simulator runtime supporting OS '$os'. Installing..." - xcodebuild -downloadPlatform iOS -quiet | xcbeautify - else - echo_succ "Found some iOS Simulator runtime supporting OS '$os'. Skipping..." - fi + echo_subtitle "Install iOS platform" + echo "▸ xcodebuild -downloadPlatform iOS -quiet" + xcodebuild -downloadPlatform iOS -quiet fi if [ "$tvOS" = "true" ]; then - echo_subtitle "Supply tvOS Simulator runtime ($os)" - echo "Check current runner for any tvOS Simulator runtime supporting OS '$os':" - if ! xctrace list devices | grep "Apple TV.*Simulator ($os)"; then - echo_warn "Found no tvOS Simulator runtime supporting OS '$os'. Installing..." - xcodebuild -downloadPlatform tvOS -quiet | xcbeautify - else - echo_succ "Found some tvOS Simulator runtime supporting OS '$os'. Skipping..." - fi + echo_subtitle "Install tvOS platform" + echo "▸ xcodebuild -downloadPlatform tvOS -quiet" + xcodebuild -downloadPlatform tvOS -quiet fi if [ "$visionOS" = "true" ]; then - echo_subtitle "Supply visionOS Simulator runtime ($os)" - echo "Check current runner for any visionOS Simulator runtime supporting OS '$os':" - if ! xctrace list devices | grep "Apple Vision.*($os)"; then - echo_warn "Found no visionOS Simulator runtime supporting OS '$os'. Installing..." - xcodebuild -downloadPlatform visionOS -quiet | xcbeautify - else - echo_succ "Found some visionOS Simulator runtime supporting OS '$os'. Skipping..." - fi + echo_subtitle "Install visionOS platform" + echo "▸ xcodebuild -downloadPlatform visionOS -quiet" + xcodebuild -downloadPlatform visionOS -quiet fi if [ "$watchOS" = "true" ]; then - echo_subtitle "Supply watchOS Simulator runtime ($os)" - echo "Check current runner for any watchOS Simulator runtime supporting OS '$os':" - if ! xctrace list devices | grep "Apple Watch.*Simulator ($os)"; then - echo_warn "Found no watchOS Simulator runtime supporting OS '$os'. Installing..." - xcodebuild -downloadPlatform watchOS -quiet | xcbeautify - else - echo_succ "Found some watchOS Simulator runtime supporting OS '$os'. Skipping..." - fi + echo_subtitle "Install watchOS platform" + echo "▸ xcodebuild -downloadPlatform watchOS -quiet" + xcodebuild -downloadPlatform watchOS -quiet fi if [ "$ssh" = "true" ]; then From f23fc1c2446c777420ac2b7a93c3951d763c6e7e Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Fri, 4 Oct 2024 14:20:55 +0200 Subject: [PATCH 13/43] Fix Session Replay ObjC interface --- Datadog/Datadog.xcodeproj/project.pbxproj | 6 +- .../Sources/SessionReplay+objc.swift | 51 ++++++----- .../Tests}/DDSessionReplayTests.swift | 88 +++++++++---------- 3 files changed, 75 insertions(+), 70 deletions(-) rename {DatadogCore/Tests/DatadogObjc => DatadogSessionReplay/Tests}/DDSessionReplayTests.swift (60%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 638d7a4191..a96f953f40 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -680,6 +680,7 @@ 61FDBA15269722B4001D9D43 /* CrashReportMinifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA14269722B4001D9D43 /* CrashReportMinifierTests.swift */; }; 61FDBA1726974CA9001D9D43 /* DDCrashReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1626974CA9001D9D43 /* DDCrashReportBuilderTests.swift */; }; 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */; }; + 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; 969B3B212C33F80500D62400 /* UIActivityIndicatorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */; }; 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */; }; @@ -1296,7 +1297,6 @@ D2A1EE452886B8B400D28DFB /* UserInfoPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A1EE432886B8B400D28DFB /* UserInfoPublisherTests.swift */; }; D2A434A22A8E3F900028E329 /* DatadogSessionReplay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6133D1F52A6ED9E100384BEF /* DatadogSessionReplay.framework */; }; D2A434AA2A8E40A20028E329 /* SessionReplay+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434A82A8E402B0028E329 /* SessionReplay+objc.swift */; }; - D2A434AE2A8E426C0028E329 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; D2A783D429A5309F003B03BB /* SwiftExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61133BBA2423979B00786299 /* SwiftExtensions.swift */; }; D2A783D529A530A0003B03BB /* SwiftExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61133BBA2423979B00786299 /* SwiftExtensions.swift */; }; D2A783D929A530EF003B03BB /* SwiftExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E36D92124373EA700BFBDB7 /* SwiftExtensionsTests.swift */; }; @@ -3574,6 +3574,7 @@ children = ( 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */, 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */, + D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, 61054F882A6EE1BA00AAA894 /* Feature */, 61054F922A6EE1BA00AAA894 /* Helpers */, 61054F7D2A6EE1BA00AAA894 /* Mocks */, @@ -4327,7 +4328,6 @@ A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */, 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */, 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, - D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); @@ -8180,7 +8180,6 @@ 615A4A8924A34FD700233986 /* DDTracerTests.swift in Sources */, 6128F58A2BA9860B00D35B08 /* DataStoreFileReaderTests.swift in Sources */, 61A2CC212A443D330000FF25 /* DDRUMConfigurationTests.swift in Sources */, - D2A434AE2A8E426C0028E329 /* DDSessionReplayTests.swift in Sources */, 61D03BE0273404E700367DE0 /* RUMDataModels+objcTests.swift in Sources */, 3CA00B072C2AE52400E6FE01 /* WatchdogTerminationsMonitoringTests.swift in Sources */, 6167E70E2B83502200C3CA2D /* DatadogCore+FeatureDirectoriesTests.swift in Sources */, @@ -8449,6 +8448,7 @@ D2BCB2A32B7B9683005C2AAB /* WKWebViewRecorderTests.swift in Sources */, 61054FC62A6EE1BA00AAA894 /* CoreGraphicsMocks.swift in Sources */, 61054FCA2A6EE1BA00AAA894 /* TestScheduler.swift in Sources */, + 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */, 61054FBD2A6EE1BA00AAA894 /* UIViewRecorderTests.swift in Sources */, 61054F952A6EE1BA00AAA894 /* SessionReplayConfigurationTests.swift in Sources */, 61054FAC2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift in Sources */, diff --git a/DatadogSessionReplay/Sources/SessionReplay+objc.swift b/DatadogSessionReplay/Sources/SessionReplay+objc.swift index 139f530970..2cc14336af 100644 --- a/DatadogSessionReplay/Sources/SessionReplay+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplay+objc.swift @@ -10,8 +10,10 @@ import DatadogInternal #if os(iOS) /// An entry point to Datadog Session Replay feature. -@objc -public final class DDSessionReplay: NSObject { +@objc(DDSessionReplay) +@objcMembers +@_spi(objc) +public final class objc_SessionReplay: NSObject { override private init() { } /// Enables Datadog Session Replay feature. @@ -23,7 +25,7 @@ public final class DDSessionReplay: NSObject { /// - Parameters: /// - configuration: Configuration of the feature. @objc - public static func enable(with configuration: DDSessionReplayConfiguration) { + public static func enable(with configuration: objc_SessionReplayConfiguration) { SessionReplay.enable(with: configuration._swift) } @@ -41,8 +43,10 @@ public final class DDSessionReplay: NSObject { } /// Session Replay feature configuration. -@objc -public final class DDSessionReplayConfiguration: NSObject { +@objc(DDSessionReplayConfiguration) +@objcMembers +@_spi(objc) +public final class objc_SessionReplayConfiguration: NSObject { internal var _swift: SessionReplay.Configuration = .init(replaySampleRate: 0) /// The sampling rate for Session Replay. It is applied in addition to the RUM session sample rate. @@ -62,7 +66,7 @@ public final class DDSessionReplayConfiguration: NSObject { /// /// Default: `.mask`. @available(*, deprecated, message: "This will be removed in future versions of the SDK. Use the new privacy levels instead.") - @objc public var defaultPrivacyLevel: DDSessionReplayConfigurationPrivacyLevel { + @objc public var defaultPrivacyLevel: objc_SessionReplayConfigurationPrivacyLevel { set { _swift.defaultPrivacyLevel = newValue._swift } get { .init(_swift.defaultPrivacyLevel) } } @@ -70,7 +74,7 @@ public final class DDSessionReplayConfiguration: NSObject { /// Defines the way texts and inputs (e.g. labels, textfields, checkboxes) should be masked. /// /// Default: `.maskAll`. - @objc public var textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel { + @objc public var textAndInputPrivacyLevel: objc_TextAndInputPrivacyLevel { set { _swift.textAndInputPrivacyLevel = newValue._swift } get { .init(_swift.textAndInputPrivacyLevel) } } @@ -78,7 +82,7 @@ public final class DDSessionReplayConfiguration: NSObject { /// Defines the way images should be masked. /// /// Default: `.maskAll`. - @objc public var imagePrivacyLevel: DDImagePrivacyLevel { + @objc public var imagePrivacyLevel: objc_ImagePrivacyLevel { set { _swift.imagePrivacyLevel = newValue._swift } get { .init(_swift.imagePrivacyLevel) } } @@ -86,7 +90,7 @@ public final class DDSessionReplayConfiguration: NSObject { /// Defines the way user touches (e.g. tap) should be masked. /// /// Default: `.mask`. - @objc public var touchPrivacyLevel: DDTouchPrivacyLevel { + @objc public var touchPrivacyLevel: objc_TouchPrivacyLevel { set { _swift.touchPrivacyLevel = newValue._swift } get { .init(_swift.touchPrivacyLevel) } } @@ -109,9 +113,9 @@ public final class DDSessionReplayConfiguration: NSObject { @objc public required init( replaySampleRate: Float, - textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel, - imagePrivacyLevel: DDImagePrivacyLevel, - touchPrivacyLevel: DDTouchPrivacyLevel + textAndInputPrivacyLevel: objc_TextAndInputPrivacyLevel, + imagePrivacyLevel: objc_ImagePrivacyLevel, + touchPrivacyLevel: objc_TouchPrivacyLevel ) { _swift = SessionReplay.Configuration( replaySampleRate: replaySampleRate, @@ -139,8 +143,9 @@ public final class DDSessionReplayConfiguration: NSObject { } /// Available privacy levels for content masking. -@objc -public enum DDSessionReplayConfigurationPrivacyLevel: Int { +@objc(DDSessionReplayConfigurationPrivacyLevel) +@_spi(objc) +public enum objc_SessionReplayConfigurationPrivacyLevel: Int { /// Record all content. case allow @@ -155,7 +160,6 @@ public enum DDSessionReplayConfigurationPrivacyLevel: Int { case .allow: return .allow case .mask: return .mask case .maskUserInput: return .maskUserInput - default: return .mask } } @@ -169,8 +173,9 @@ public enum DDSessionReplayConfigurationPrivacyLevel: Int { } /// Available privacy levels for text and input masking. -@objc -public enum DDTextAndInputPrivacyLevel: Int { +@objc(DDTextAndInputPrivacyLevel) +@_spi(objc) +public enum objc_TextAndInputPrivacyLevel: Int { /// Show all text except sensitive input (eg. password fields). case maskSensitiveInputs @@ -185,7 +190,6 @@ public enum DDTextAndInputPrivacyLevel: Int { case .maskSensitiveInputs: return .maskSensitiveInputs case .maskAllInputs: return .maskAllInputs case .maskAll: return .maskAll - default: return .maskAll } } @@ -199,8 +203,9 @@ public enum DDTextAndInputPrivacyLevel: Int { } /// Available image privacy levels for image masking. -@objc -public enum DDImagePrivacyLevel: Int { +@objc(DDImagePrivacyLevel) +@_spi(objc) +public enum objc_ImagePrivacyLevel: Int { /// Only SF Symbols and images loaded using UIImage(named:) that are bundled within the application package will be recorded. case maskNonBundledOnly /// No images will be recorded. @@ -226,8 +231,9 @@ public enum DDImagePrivacyLevel: Int { } /// Available privacy levels for content masking. -@objc -public enum DDTouchPrivacyLevel: Int { +@objc(DDTouchPrivacyLevel) +@_spi(objc) +public enum objc_TouchPrivacyLevel: Int { /// Show all touches. case show @@ -238,7 +244,6 @@ public enum DDTouchPrivacyLevel: Int { switch self { case .show: return .show case .hide: return .hide - default: return .hide } } diff --git a/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayTests.swift similarity index 60% rename from DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift rename to DatadogSessionReplay/Tests/DDSessionReplayTests.swift index e5364b662d..ab9c7c7937 100644 --- a/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayTests.swift @@ -9,7 +9,7 @@ import XCTest import TestUtilities import DatadogInternal - +@_spi(objc) @testable import DatadogSessionReplay class DDSessionReplayTests: XCTestCase { @@ -18,7 +18,7 @@ class DDSessionReplayTests: XCTestCase { let sampleRate: Float = .mockRandom(min: 0, max: 100) // When - let config = DDSessionReplayConfiguration(replaySampleRate: sampleRate) + let config = objc_SessionReplayConfiguration(replaySampleRate: sampleRate) // Then XCTAssertEqual(config._swift.replaySampleRate, sampleRate) @@ -31,13 +31,13 @@ class DDSessionReplayTests: XCTestCase { func testConfigurationWithNewApi() { // Given - let textAndInputPrivacy: DDTextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! - let touchPrivacy: DDTouchPrivacyLevel = [.show, .hide].randomElement()! - let imagePrivacy: DDImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + let textAndInputPrivacy: objc_TextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + let touchPrivacy: objc_TouchPrivacyLevel = [.show, .hide].randomElement()! + let imagePrivacy: objc_ImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let sampleRate: Float = .mockRandom(min: 0, max: 100) // When - let config = DDSessionReplayConfiguration( + let config = objc_SessionReplayConfiguration( replaySampleRate: sampleRate, textAndInputPrivacyLevel: textAndInputPrivacy, imagePrivacyLevel: imagePrivacy, @@ -55,14 +55,14 @@ class DDSessionReplayTests: XCTestCase { func testConfigurationOverrides() { // Given let sampleRate: Float = .mockRandom(min: 0, max: 100) - let privacy: DDSessionReplayConfigurationPrivacyLevel = [.allow, .mask, .maskUserInput].randomElement()! - let textAndInputPrivacy: DDTextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! - let imagePrivacy: DDImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! - let touchPrivacy: DDTouchPrivacyLevel = [.show, .hide].randomElement()! + let privacy: objc_SessionReplayConfigurationPrivacyLevel = [.allow, .mask, .maskUserInput].randomElement()! + let textAndInputPrivacy: objc_TextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + let imagePrivacy: objc_ImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + let touchPrivacy: objc_TouchPrivacyLevel = [.show, .hide].randomElement()! let url: URL = .mockRandom() // When - let config = DDSessionReplayConfiguration(replaySampleRate: 100) + let config = objc_SessionReplayConfiguration(replaySampleRate: 100) config.replaySampleRate = sampleRate config.defaultPrivacyLevel = privacy config.textAndInputPrivacyLevel = textAndInputPrivacy @@ -82,13 +82,13 @@ class DDSessionReplayTests: XCTestCase { func testConfigurationOverridesWithNewApi() { // Given let sampleRate: Float = .mockRandom(min: 0, max: 100) - let textAndInputPrivacy: DDTextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! - let imagePrivacy: DDImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! - let touchPrivacy: DDTouchPrivacyLevel = [.show, .hide].randomElement()! + let textAndInputPrivacy: objc_TextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + let imagePrivacy: objc_ImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + let touchPrivacy: objc_TouchPrivacyLevel = [.show, .hide].randomElement()! let url: URL = .mockRandom() // When - let config = DDSessionReplayConfiguration( + let config = objc_SessionReplayConfiguration( replaySampleRate: 100, textAndInputPrivacyLevel: .maskAll, imagePrivacyLevel: .maskAll, @@ -109,41 +109,41 @@ class DDSessionReplayTests: XCTestCase { } func testPrivacyLevelsInterop() { - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel.allow._swift, .allow) - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel.mask._swift, .mask) - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel.maskUserInput._swift, .maskUserInput) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel.allow._swift, .allow) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel.mask._swift, .mask) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel.maskUserInput._swift, .maskUserInput) - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel(.allow), .allow) - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel(.mask), .mask) - XCTAssertEqual(DDSessionReplayConfigurationPrivacyLevel(.maskUserInput), .maskUserInput) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel(.allow), .allow) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel(.mask), .mask) + XCTAssertEqual(objc_SessionReplayConfigurationPrivacyLevel(.maskUserInput), .maskUserInput) } func testTextAndInputPrivacyLevelsInterop() { - XCTAssertEqual(DDTextAndInputPrivacyLevel.maskAll._swift, .maskAll) - XCTAssertEqual(DDTextAndInputPrivacyLevel.maskAllInputs._swift, .maskAllInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevel.maskSensitiveInputs._swift, .maskSensitiveInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevel.maskAll._swift, .maskAll) + XCTAssertEqual(objc_TextAndInputPrivacyLevel.maskAllInputs._swift, .maskAllInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevel.maskSensitiveInputs._swift, .maskSensitiveInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevel(.maskAll), .maskAll) - XCTAssertEqual(DDTextAndInputPrivacyLevel(.maskAllInputs), .maskAllInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevel(.maskSensitiveInputs), .maskSensitiveInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevel(.maskAll), .maskAll) + XCTAssertEqual(objc_TextAndInputPrivacyLevel(.maskAllInputs), .maskAllInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevel(.maskSensitiveInputs), .maskSensitiveInputs) } func testImagePrivacyLevelsInterop() { - XCTAssertEqual(DDImagePrivacyLevel.maskAll._swift, .maskAll) - XCTAssertEqual(DDImagePrivacyLevel.maskNonBundledOnly._swift, .maskNonBundledOnly) - XCTAssertEqual(DDImagePrivacyLevel.maskNone._swift, .maskNone) + XCTAssertEqual(objc_ImagePrivacyLevel.maskAll._swift, .maskAll) + XCTAssertEqual(objc_ImagePrivacyLevel.maskNonBundledOnly._swift, .maskNonBundledOnly) + XCTAssertEqual(objc_ImagePrivacyLevel.maskNone._swift, .maskNone) - XCTAssertEqual(DDImagePrivacyLevel(.maskAll), .maskAll) - XCTAssertEqual(DDImagePrivacyLevel(.maskNonBundledOnly), .maskNonBundledOnly) - XCTAssertEqual(DDImagePrivacyLevel(.maskNone), .maskNone) + XCTAssertEqual(objc_ImagePrivacyLevel(.maskAll), .maskAll) + XCTAssertEqual(objc_ImagePrivacyLevel(.maskNonBundledOnly), .maskNonBundledOnly) + XCTAssertEqual(objc_ImagePrivacyLevel(.maskNone), .maskNone) } func testTouchPrivacyLevelsInterop() { - XCTAssertEqual(DDTouchPrivacyLevel.show._swift, .show) - XCTAssertEqual(DDTouchPrivacyLevel.hide._swift, .hide) + XCTAssertEqual(objc_TouchPrivacyLevel.show._swift, .show) + XCTAssertEqual(objc_TouchPrivacyLevel.hide._swift, .hide) - XCTAssertEqual(DDTouchPrivacyLevel(.show), .show) - XCTAssertEqual(DDTouchPrivacyLevel(.hide), .hide) + XCTAssertEqual(objc_TouchPrivacyLevel(.show), .show) + XCTAssertEqual(objc_TouchPrivacyLevel(.hide), .hide) } func testWhenEnabled() throws { @@ -152,10 +152,10 @@ class DDSessionReplayTests: XCTestCase { CoreRegistry.register(default: core) defer { CoreRegistry.unregisterDefault() } - let config = DDSessionReplayConfiguration(replaySampleRate: 42) + let config = objc_SessionReplayConfiguration(replaySampleRate: 42) // When - DDSessionReplay.enable(with: config) + objc_SessionReplay.enable(with: config) // Then let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) @@ -171,12 +171,12 @@ class DDSessionReplayTests: XCTestCase { // Given let core = FeatureRegistrationCoreMock() CoreRegistry.register(default: core) - let textAndInputPrivacy: DDTextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! - let imagePrivacy: DDImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! - let touchPrivacy: DDTouchPrivacyLevel = [.show, .hide].randomElement()! + let textAndInputPrivacy: objc_TextAndInputPrivacyLevel = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + let imagePrivacy: objc_ImagePrivacyLevel = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + let touchPrivacy: objc_TouchPrivacyLevel = [.show, .hide].randomElement()! defer { CoreRegistry.unregisterDefault() } - let config = DDSessionReplayConfiguration( + let config = objc_SessionReplayConfiguration( replaySampleRate: 42, textAndInputPrivacyLevel: textAndInputPrivacy, imagePrivacyLevel: imagePrivacy, @@ -184,7 +184,7 @@ class DDSessionReplayTests: XCTestCase { ) // When - DDSessionReplay.enable(with: config) + objc_SessionReplay.enable(with: config) // Then let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) From ce213b9b4d0f2f37a189524b497eedb99c3dcd21 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Mon, 7 Oct 2024 16:37:49 +0200 Subject: [PATCH 14/43] Update Swift API surface --- api-surface-swift | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api-surface-swift b/api-surface-swift index aa258a9808..ad45de4623 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -2159,32 +2159,32 @@ public struct SessionReplaySpecificElement: SessionReplayNodeSemantics public let subtreeStrategy: SessionReplayNodeSubtreeStrategy public let nodes: [SessionReplayNode] public init(subtreeStrategy: SessionReplayNodeSubtreeStrategy,nodes: [SessionReplayNode]) -public final class DDSessionReplay: NSObject - public static func enable(with configuration: DDSessionReplayConfiguration) +public final class objc_SessionReplay: NSObject + public static func enable(with configuration: objc_SessionReplayConfiguration) public static func startRecording() public static func stopRecording() -public final class DDSessionReplayConfiguration: NSObject +public final class objc_SessionReplayConfiguration: NSObject @objc public var replaySampleRate: Float - @objc public var defaultPrivacyLevel: DDSessionReplayConfigurationPrivacyLevel - @objc public var textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel - @objc public var imagePrivacyLevel: DDImagePrivacyLevel - @objc public var touchPrivacyLevel: DDTouchPrivacyLevel + @objc public var defaultPrivacyLevel: objc_SessionReplayConfigurationPrivacyLevel + @objc public var textAndInputPrivacyLevel: objc_TextAndInputPrivacyLevel + @objc public var imagePrivacyLevel: objc_ImagePrivacyLevel + @objc public var touchPrivacyLevel: objc_TouchPrivacyLevel @objc public var customEndpoint: URL? - public required init(replaySampleRate: Float,textAndInputPrivacyLevel: DDTextAndInputPrivacyLevel,imagePrivacyLevel: DDImagePrivacyLevel,touchPrivacyLevel: DDTouchPrivacyLevel) + public required init(replaySampleRate: Float,textAndInputPrivacyLevel: objc_TextAndInputPrivacyLevel,imagePrivacyLevel: objc_ImagePrivacyLevel,touchPrivacyLevel: objc_TouchPrivacyLevel) public required init(replaySampleRate: Float) -public enum DDSessionReplayConfigurationPrivacyLevel: Int +public enum objc_SessionReplayConfigurationPrivacyLevel: Int case allow case mask case maskUserInput -public enum DDTextAndInputPrivacyLevel: Int +public enum objc_TextAndInputPrivacyLevel: Int case maskSensitiveInputs case maskAllInputs case maskAll -public enum DDImagePrivacyLevel: Int +public enum objc_ImagePrivacyLevel: Int case maskNonBundledOnly case maskAll case maskNone -public enum DDTouchPrivacyLevel: Int +public enum objc_TouchPrivacyLevel: Int case show case hide public enum SessionReplay From 9915bd704772386b429136081e45c30f991a09f3 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 10 Oct 2024 13:28:39 +0200 Subject: [PATCH 15/43] RUM-6376 Only enable single core for Session Replay --- DatadogSessionReplay/Sources/SessionReplay.swift | 8 ++++++++ .../Tests/SessionReplayTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/DatadogSessionReplay/Sources/SessionReplay.swift b/DatadogSessionReplay/Sources/SessionReplay.swift index 38551d3dc6..3f60e9f04f 100644 --- a/DatadogSessionReplay/Sources/SessionReplay.swift +++ b/DatadogSessionReplay/Sources/SessionReplay.swift @@ -69,6 +69,14 @@ public enum SessionReplay { description: "Datadog SDK must be initialized before calling `SessionReplay.enable(with:)`." ) } + + guard core.get(feature: SessionReplayFeature.self) == nil else { + core.telemetry.send(telemetry: .debug(id: "1", message: "Session Replay has already been enabled", attributes: nil)) + throw ProgrammerError( + description: "Session Replay is already enabled and does not support multiple instances. The existing instance will continue to be used." + ) + } + guard configuration.replaySampleRate > 0 else { return } diff --git a/DatadogSessionReplay/Tests/SessionReplayTests.swift b/DatadogSessionReplay/Tests/SessionReplayTests.swift index c3a36e33cd..3788630129 100644 --- a/DatadogSessionReplay/Tests/SessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayTests.swift @@ -52,6 +52,22 @@ class SessionReplayTests: XCTestCase { ) } + func testWhenEnabledMultipleTimes_itPrintsError() { + let printFunction = PrintFunctionMock() + consolePrint = printFunction.print + defer { consolePrint = { message, _ in print(message) } } + + // When + SessionReplay.enable(with: config, in: core) + SessionReplay.enable(with: config, in: core) + + // Then + XCTAssertEqual( + printFunction.printedMessage, + "🔥 Datadog SDK usage error: Session Replay is already enabled and does not support multiple instances. The existing instance will continue to be used." + ) + } + // MARK: - Configuration Tests func testWhenEnabledWithDefaultConfiguration() throws { From 04891bfd60ebeaa022fb6aba54881da511b56abb Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 10 Oct 2024 13:53:58 +0200 Subject: [PATCH 16/43] Add ObjC checkbox to PR template --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index af7892ad36..744ed25504 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,3 +10,4 @@ A brief description of implementation details of this PR. - [ ] Feature or bugfix MUST have appropriate tests (unit, integration) - [ ] Make sure each commit and the PR mention the Issue number or JIRA reference - [ ] Add CHANGELOG entry for user facing changes +- [ ] Add Objective-C interface for public APIs (see our [guidelines](https://datadoghq.atlassian.net/wiki/spaces/RUMP/pages/3157787243/RFC+-+Modular+Objective-C+Interface#Recommended-solution) (internal)) and run `make api-surface` From 616e25c3beb36fcbede4ad6408181fa9351ed74c Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Wed, 9 Oct 2024 12:55:42 +0200 Subject: [PATCH 17/43] RUM-6354 Fix conflict between SwiftUI and UIKit instrumentation so when both track a tap, then the SwiftUI one takes higher priority. This solves the problem of tracking SwiftUI button taps when they are nested in UIKit table view cells. --- Datadog/Datadog.xcodeproj/project.pbxproj | 30 ++++--- .../Tests/Datadog/Mocks/RUMFeatureMocks.swift | 15 +++- .../RUM/UIApplicationSwizzlerTests.swift | 2 +- .../Actions/RUMActionsHandler.swift | 89 +++++++++++++++++++ .../SwiftUI/SwiftUIActionModifier.swift | 11 ++- .../Actions/UIKit/UIApplicationSwizzler.swift | 6 +- ...dler.swift => UIEventCommandFactory.swift} | 50 +---------- .../RUMCommandSubscriber.swift | 14 +++ .../Instrumentation/RUMInstrumentation.swift | 68 +++++++------- .../Views/RUMViewsHandler.swift | 7 +- .../Views/SwiftUI/SwiftUIViewModifier.swift | 4 +- DatadogRUM/Sources/RUMMonitor/Monitor.swift | 2 + .../Sources/RUMMonitor/RUMCommand.swift | 4 + .../Scopes/RUMUserActionScope.swift | 4 + .../RUMMonitor/Scopes/RUMViewScope.swift | 12 ++- ...sts.swift => RUMActionsHandlerTests.swift} | 66 +++++++++----- .../RUMInstrumentationTests.swift | 2 +- DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift | 8 +- .../RUMMonitor/Scopes/RUMViewScopeTests.swift | 85 ++++++++++++++++++ DatadogRUM/Tests/RUMTests.swift | 10 ++- 20 files changed, 350 insertions(+), 139 deletions(-) create mode 100644 DatadogRUM/Sources/Instrumentation/Actions/RUMActionsHandler.swift rename DatadogRUM/Sources/Instrumentation/Actions/UIKit/{UIKitRUMUserActionsHandler.swift => UIEventCommandFactory.swift} (75%) rename DatadogRUM/Tests/Instrumentation/Actions/{UIKitRUMUserActionsHandlerTests.swift => RUMActionsHandlerTests.swift} (84%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index a96f953f40..d416e5619f 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -309,6 +309,8 @@ 611720D52524D9FB00634D9E /* DDURLSessionDelegate+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611720D42524D9FB00634D9E /* DDURLSessionDelegate+objc.swift */; }; 61181CDC2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61181CDB2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift */; }; 61181CDD2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61181CDB2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift */; }; + 61193AAE2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61193AAD2CB54C7300C3CDF5 /* RUMActionsHandler.swift */; }; + 61193AAF2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61193AAD2CB54C7300C3CDF5 /* RUMActionsHandler.swift */; }; 6121627C247D220500AC5D67 /* TracingWithLoggingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61216279247D21FE00AC5D67 /* TracingWithLoggingIntegrationTests.swift */; }; 61216B762666DDA10089DCD1 /* LoggerConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61216B752666DDA10089DCD1 /* LoggerConfigurationTests.swift */; }; 61216B7B2667A9AE0089DCD1 /* LogsConfigurationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61216B7A2667A9AE0089DCD1 /* LogsConfigurationE2ETests.swift */; }; @@ -987,7 +989,7 @@ D23F8E8B29DDCD28001CFAE8 /* RUMContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63824BF19B4008053F2 /* RUMContext.swift */; }; D23F8E8C29DDCD28001CFAE8 /* RUMBaggageKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2F329CC88060063802D /* RUMBaggageKeys.swift */; }; D23F8E8D29DDCD28001CFAE8 /* VitalRefreshRateReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3CA6826775A3500B16871 /* VitalRefreshRateReader.swift */; }; - D23F8E8E29DDCD28001CFAE8 /* UIKitRUMUserActionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIKitRUMUserActionsHandler.swift */; }; + D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */; }; D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD824C7269500589570 /* RUMUUIDGenerator.swift */; }; D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; @@ -1007,7 +1009,7 @@ D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */; }; D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; - D23F8EB829DDCD38001CFAE8 /* UIKitRUMUserActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */; }; + D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; D23F8EB929DDCD38001CFAE8 /* RUMFeatureMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333024B75DFC003D6C4E /* RUMFeatureMocks.swift */; }; D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */; }; D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */; }; @@ -1204,7 +1206,7 @@ D29A9F6629DD85BB005C54A4 /* RUMUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */; }; D29A9F6729DD85BB005C54A4 /* RUMOperatingSystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616C0A9D28573DFF00C13264 /* RUMOperatingSystemInfo.swift */; }; D29A9F6829DD85BB005C54A4 /* RUMContextAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26D294395A300134409 /* RUMContextAttributes.swift */; }; - D29A9F6929DD85BB005C54A4 /* UIKitRUMUserActionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIKitRUMUserActionsHandler.swift */; }; + D29A9F6929DD85BB005C54A4 /* UIEventCommandFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */; }; D29A9F6A29DD85BB005C54A4 /* SwiftUIViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D249859F2728042200B4F72D /* SwiftUIViewModifier.swift */; }; D29A9F6B29DD85BB005C54A4 /* VitalInfoSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9973F0268DF69500D8059B /* VitalInfoSampler.swift */; }; D29A9F6D29DD85BB005C54A4 /* UIApplicationSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */; }; @@ -1252,7 +1254,7 @@ D29A9FA729DDB483005C54A4 /* RUMCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715F624DC0CDE00FC0F69 /* RUMCommandTests.swift */; }; D29A9FAA29DDB483005C54A4 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; D29A9FAB29DDB483005C54A4 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; - D29A9FAC29DDB483005C54A4 /* UIKitRUMUserActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */; }; + D29A9FAC29DDB483005C54A4 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; 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 */; }; @@ -2366,6 +2368,7 @@ 611529AD25E3E429004F740E /* ValuePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePublisherTests.swift; sourceTree = ""; }; 611720D42524D9FB00634D9E /* DDURLSessionDelegate+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DDURLSessionDelegate+objc.swift"; sourceTree = ""; }; 61181CDB2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FatalErrorContextNotifierTests.swift; sourceTree = ""; }; + 61193AAD2CB54C7300C3CDF5 /* RUMActionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMActionsHandler.swift; sourceTree = ""; }; 611F82022563C66100CB9BDB /* UIKitRUMViewsPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMViewsPredicateTests.swift; sourceTree = ""; }; 61216275247D1CD700AC5D67 /* TracingWithLoggingIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingWithLoggingIntegration.swift; sourceTree = ""; }; 61216279247D21FE00AC5D67 /* TracingWithLoggingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingWithLoggingIntegrationTests.swift; sourceTree = ""; }; @@ -2414,7 +2417,7 @@ 613F9C172BAC3527007C7606 /* DatadogCore+FeatureDataStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatadogCore+FeatureDataStoreTests.swift"; sourceTree = ""; }; 613F9C1A2BB03188007C7606 /* FeatureScopeMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureScopeMock.swift; sourceTree = ""; }; 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationSwizzler.swift; sourceTree = ""; }; - 6141015A251A601D00E3C2D9 /* UIKitRUMUserActionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMUserActionsHandler.swift; sourceTree = ""; }; + 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIEventCommandFactory.swift; sourceTree = ""; }; 61410166251A661D00E3C2D9 /* UIApplicationSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationSwizzlerTests.swift; sourceTree = ""; }; 61411B0F24EC15AC0012EAB2 /* Casting+RUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Casting+RUM.swift"; sourceTree = ""; }; 614396712A67D74F00197326 /* BatchMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchMetrics.swift; sourceTree = ""; }; @@ -2458,7 +2461,7 @@ 615A4A8C24A356A000233986 /* OTSpanContext+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OTSpanContext+objc.swift"; sourceTree = ""; }; 615B0F8A2BB33C2800E9ED6C /* AppHangsMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsMonitorTests.swift; sourceTree = ""; }; 615B0F8D2BB33E0400E9ED6C /* DataStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreMock.swift; sourceTree = ""; }; - 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMUserActionsHandlerTests.swift; sourceTree = ""; }; + 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMActionsHandlerTests.swift; sourceTree = ""; }; 615CC40B2694A56D0005F08C /* SwiftExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftExtensions.swift; sourceTree = ""; }; 615CC40F2694A64D0005F08C /* SwiftExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftExtensionTests.swift; sourceTree = ""; }; 615CC4122695957C0005F08C /* CrashReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportTests.swift; sourceTree = ""; }; @@ -4639,7 +4642,7 @@ 6141014C251A577D00E3C2D9 /* Actions */ = { isa = PBXGroup; children = ( - 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */, + 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */, ); path = Actions; sourceTree = ""; @@ -4647,6 +4650,7 @@ 6141014D251A578D00E3C2D9 /* Actions */ = { isa = PBXGroup; children = ( + 61193AAD2CB54C7300C3CDF5 /* RUMActionsHandler.swift */, D29D5A4A273BF81500A687C1 /* UIKit */, D29D5A4B273BF82200A687C1 /* SwiftUI */, ); @@ -6303,7 +6307,7 @@ D29D5A4A273BF81500A687C1 /* UIKit */ = { isa = PBXGroup; children = ( - 6141015A251A601D00E3C2D9 /* UIKitRUMUserActionsHandler.swift */, + 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */, 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */, F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */, ); @@ -8790,6 +8794,7 @@ D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */, D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */, 3CFF4F982C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, + 61193AAF2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */, D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */, D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */, D23F8E5F29DDCD28001CFAE8 /* UIApplicationSwizzler.swift in Sources */, @@ -8860,7 +8865,7 @@ 6174D6212C009C6300EC7469 /* SessionEndedMetricController.swift in Sources */, D23F8E8D29DDCD28001CFAE8 /* VitalRefreshRateReader.swift in Sources */, 3CFF4F8C2C09E61A006F191D /* WatchdogTerminationAppState.swift in Sources */, - D23F8E8E29DDCD28001CFAE8 /* UIKitRUMUserActionsHandler.swift in Sources */, + D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */, D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */, 61DCC84F2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */, ); @@ -8906,7 +8911,7 @@ D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */, 61C713CB2A3DC22700FA735A /* RUMTests.swift in Sources */, - D23F8EB829DDCD38001CFAE8 /* UIKitRUMUserActionsHandlerTests.swift in Sources */, + D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */, D23F8EB929DDCD38001CFAE8 /* RUMFeatureMocks.swift in Sources */, 61C713AE2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */, @@ -9124,6 +9129,7 @@ D29A9F8429DD85BB005C54A4 /* RUMResourceScope.swift in Sources */, D29A9F7329DD85BB005C54A4 /* RUMApplicationScope.swift in Sources */, 3CFF4F972C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, + 61193AAE2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */, D29A9F6A29DD85BB005C54A4 /* SwiftUIViewModifier.swift in Sources */, D29A9F6429DD85BB005C54A4 /* VitalInfo.swift in Sources */, D29A9F6D29DD85BB005C54A4 /* UIApplicationSwizzler.swift in Sources */, @@ -9194,7 +9200,7 @@ 6174D6202C009C6300EC7469 /* SessionEndedMetricController.swift in Sources */, D29A9F8929DD85BB005C54A4 /* VitalRefreshRateReader.swift in Sources */, 3CFF4F8B2C09E61A006F191D /* WatchdogTerminationAppState.swift in Sources */, - D29A9F6929DD85BB005C54A4 /* UIKitRUMUserActionsHandler.swift in Sources */, + D29A9F6929DD85BB005C54A4 /* UIEventCommandFactory.swift in Sources */, D29A9F5229DD85BB005C54A4 /* RUMUUIDGenerator.swift in Sources */, 61DCC84E2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */, ); @@ -9240,7 +9246,7 @@ D29A9F9F29DDB483005C54A4 /* RUMApplicationScopeTests.swift in Sources */, D29A9FAA29DDB483005C54A4 /* RUMViewsHandlerTests.swift in Sources */, 61C713CA2A3DC22700FA735A /* RUMTests.swift in Sources */, - D29A9FAC29DDB483005C54A4 /* UIKitRUMUserActionsHandlerTests.swift in Sources */, + D29A9FAC29DDB483005C54A4 /* RUMActionsHandlerTests.swift in Sources */, D29A9FC029DDB540005C54A4 /* RUMFeatureMocks.swift in Sources */, 61C713AD2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, D29A9FB729DDB483005C54A4 /* ViewIdentifierTests.swift in Sources */, diff --git a/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift b/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift index 2e37d1b439..ca56405fb8 100644 --- a/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift @@ -507,11 +507,12 @@ extension RUMStartUserActionCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], + instrumentation: InstrumentationType = .manual, actionType: RUMActionType = .swipe, name: String = .mockAny() ) -> RUMStartUserActionCommand { return RUMStartUserActionCommand( - time: time, attributes: attributes, actionType: actionType, name: name + time: time, attributes: attributes, instrumentation: instrumentation, actionType: actionType, name: name ) } } @@ -555,11 +556,12 @@ extension RUMAddUserActionCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], + instrumentation: InstrumentationType = .manual, actionType: RUMActionType = .tap, name: String = .mockAny() ) -> RUMAddUserActionCommand { return RUMAddUserActionCommand( - time: time, attributes: attributes, actionType: actionType, name: name + time: time, attributes: attributes, instrumentation: instrumentation, actionType: actionType, name: name ) } } @@ -936,6 +938,7 @@ extension RUMUserActionScope { startTime: Date = .mockAny(), serverTimeOffset: TimeInterval = .zero, isContinuous: Bool = .mockAny(), + instrumentation: InstrumentationType = .manual, onActionEventSent: @escaping (RUMActionEvent) -> Void = { _ in } ) -> RUMUserActionScope { return RUMUserActionScope( @@ -947,6 +950,7 @@ extension RUMUserActionScope { startTime: startTime, serverTimeOffset: serverTimeOffset, isContinuous: isContinuous, + instrumentation: instrumentation, onActionEventSent: onActionEventSent ) } @@ -1037,9 +1041,10 @@ class UIPressRUMActionsPredicateMock: UIPressRUMActionsPredicate { } } -class UIKitRUMUserActionsHandlerMock: UIEventHandler { +class RUMActionsHandlerMock: RUMActionsHandling { var onSubscribe: ((RUMCommandSubscriber) -> Void)? var onSendEvent: ((UIApplication, UIEvent) -> Void)? + var onViewModifierTapped: ((String, [String: any Encodable]) -> Void)? func publish(to subscriber: RUMCommandSubscriber) { onSubscribe?(subscriber) @@ -1048,6 +1053,10 @@ class UIKitRUMUserActionsHandlerMock: UIEventHandler { func notify_sendEvent(application: UIApplication, event: UIEvent) { onSendEvent?(application, event) } + + func notify_viewModifierTapped(actionName: String, actionAttributes: [String: any Encodable]) { + onViewModifierTapped?(actionName, actionAttributes) + } } class SamplingBasedVitalReaderMock: SamplingBasedVitalReader { diff --git a/DatadogCore/Tests/Datadog/RUM/UIApplicationSwizzlerTests.swift b/DatadogCore/Tests/Datadog/RUM/UIApplicationSwizzlerTests.swift index d111411151..3f411d50d0 100644 --- a/DatadogCore/Tests/Datadog/RUM/UIApplicationSwizzlerTests.swift +++ b/DatadogCore/Tests/Datadog/RUM/UIApplicationSwizzlerTests.swift @@ -11,7 +11,7 @@ import XCTest #if !os(tvOS) class UIApplicationSwizzlerTests: XCTestCase { - private let handler = UIKitRUMUserActionsHandlerMock() + private let handler = RUMActionsHandlerMock() private lazy var swizzler = try! UIApplicationSwizzler(handler: handler) override func setUp() { diff --git a/DatadogRUM/Sources/Instrumentation/Actions/RUMActionsHandler.swift b/DatadogRUM/Sources/Instrumentation/Actions/RUMActionsHandler.swift new file mode 100644 index 0000000000..cac552eb66 --- /dev/null +++ b/DatadogRUM/Sources/Instrumentation/Actions/RUMActionsHandler.swift @@ -0,0 +1,89 @@ +/* + * 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 +import DatadogInternal + +internal protocol RUMActionsHandling: RUMCommandPublisher { + /// Tracks RUM actions in UIKit by responding to `UIApplication.sendEvent(applicatoin:event:)` being called. + func notify_sendEvent(application: UIApplication, event: UIEvent) + /// Tracks RUM actions in SwiftUI by being notified from `RUMTapActionModifier`. + func notify_viewModifierTapped(actionName: String, actionAttributes: [String: Encodable]) +} + +internal final class RUMActionsHandler: RUMActionsHandling { + /// Factory interface creating "add action" commands from UIEvent intercepted in UIKit. + /// It is `nil` when `UIKit` instrumentation is not enabled. + private let uiKitCommandsFactory: UIEventCommandFactory? + private let dateProvider: DateProvider + + weak var subscriber: RUMCommandSubscriber? + + convenience init(dateProvider: DateProvider, predicate: UITouchRUMActionsPredicate?) { + self.init( + dateProvider: dateProvider, + uiKitCommandsFactory: predicate.map { UITouchCommandFactory(dateProvider: dateProvider, predicate: $0) } + ) + } + + convenience init(dateProvider: DateProvider, predicate: UIPressRUMActionsPredicate?) { + self.init( + dateProvider: dateProvider, + uiKitCommandsFactory: predicate.map { UIPressCommandFactory(dateProvider: dateProvider, predicate: $0) } + ) + } + + init(dateProvider: DateProvider, uiKitCommandsFactory: UIEventCommandFactory?) { + self.uiKitCommandsFactory = uiKitCommandsFactory + self.dateProvider = dateProvider + } + + func publish(to subscriber: RUMCommandSubscriber) { + self.subscriber = subscriber + } + + /// Tracks UIKit RUM actions in response to `UIApplication.sendEvent(application:event:)` event. + func notify_sendEvent(application: UIApplication, event: UIEvent) { + guard let command = uiKitCommandsFactory?.command(from: event) else { + return // Not a "tap" event or doesn't have the view. + } + + guard let subscriber = subscriber else { + DD.logger.warn( + """ + A RUM action was detected in UIKit, but RUM tracking appears to be disabled. + Ensure `RUM.enable()` is called before any actions are triggered. + """ + ) + return + } + + subscriber.process(command: command) + } + + /// Tracks SwiftUI RUM actions in response to `SwiftUI.TapGesture.onEnded` event. + func notify_viewModifierTapped(actionName: String, actionAttributes: [String: Encodable]) { + let command = RUMAddUserActionCommand( + time: dateProvider.now, + attributes: actionAttributes, + instrumentation: .swiftui, + actionType: .tap, + name: actionName + ) + + guard let subscriber = subscriber else { + DD.logger.warn( + """ + A RUM action was detected in SwiftUI, but RUM tracking appears to be disabled. + Ensure `RUM.enable()` is called before any actions are triggered. + """ + ) + return + } + + subscriber.process(command: command) + } +} diff --git a/DatadogRUM/Sources/Instrumentation/Actions/SwiftUI/SwiftUIActionModifier.swift b/DatadogRUM/Sources/Instrumentation/Actions/SwiftUI/SwiftUIActionModifier.swift index 840d396023..a790e632ea 100644 --- a/DatadogRUM/Sources/Instrumentation/Actions/SwiftUI/SwiftUIActionModifier.swift +++ b/DatadogRUM/Sources/Instrumentation/Actions/SwiftUI/SwiftUIActionModifier.swift @@ -10,8 +10,8 @@ import DatadogInternal #if !os(tvOS) -/// `SwiftUI.ViewModifier` for RUM which invoke `addUserAction` from the -/// global RUM Monitor when the modified view receives a tap. +/// `SwiftUI.ViewModifier` which notifes RUM instrumentation when modified view is tapped. +/// It makes an entry point to RUM actions instrumentation in SwiftUI. @available(iOS 13, *) internal struct RUMTapActionModifier: SwiftUI.ViewModifier { /// The SDK core instance. @@ -32,8 +32,11 @@ internal struct RUMTapActionModifier: SwiftUI.ViewModifier { guard let core = core else { return // core was deallocated } - RUMMonitor.shared(in: core) - .addAction(type: .tap, name: name, attributes: attributes) + guard let feature = core.get(feature: RUMFeature.self) else { + return // RUM not enabled + } + feature.instrumentation.actionsHandler + .notify_viewModifierTapped(actionName: name, actionAttributes: attributes) } ) } diff --git a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift index acf02b7a3e..efddb1cbfe 100644 --- a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift +++ b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIApplicationSwizzler.swift @@ -10,7 +10,7 @@ import DatadogInternal internal class UIApplicationSwizzler { let sendEvent: SendEvent - init(handler: UIEventHandler) throws { + init(handler: RUMActionsHandling) throws { sendEvent = try SendEvent(handler: handler) } @@ -31,9 +31,9 @@ internal class UIApplicationSwizzler { > { private static let selector = #selector(UIApplication.sendEvent(_:)) private let method: Method - private let handler: UIEventHandler + private let handler: RUMActionsHandling - init(handler: UIEventHandler) throws { + init(handler: RUMActionsHandling) throws { self.method = try dd_class_getInstanceMethod(UIApplication.self, Self.selector) self.handler = handler } diff --git a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIKitRUMUserActionsHandler.swift b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIEventCommandFactory.swift similarity index 75% rename from DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIKitRUMUserActionsHandler.swift rename to DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIEventCommandFactory.swift index bb079ab459..940c59a783 100644 --- a/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIKitRUMUserActionsHandler.swift +++ b/DatadogRUM/Sources/Instrumentation/Actions/UIKit/UIEventCommandFactory.swift @@ -7,58 +7,10 @@ import UIKit import DatadogInternal -internal protocol UIEventHandler: RUMCommandPublisher { - func notify_sendEvent(application: UIApplication, event: UIEvent) -} - internal protocol UIEventCommandFactory { func command(from event: UIEvent) -> RUMAddUserActionCommand? } -internal class UIKitRUMUserActionsHandler: UIEventHandler { - let factory: UIEventCommandFactory - - convenience init(dateProvider: DateProvider, predicate: UITouchRUMActionsPredicate) { - let factory = UITouchCommandFactory(dateProvider: dateProvider, predicate: predicate) - self.init(factory: factory) - } - - convenience init(dateProvider: DateProvider, predicate: UIPressRUMActionsPredicate) { - let factory = UIPressCommandFactory(dateProvider: dateProvider, predicate: predicate) - self.init(factory: factory) - } - - init(factory: UIEventCommandFactory) { - self.factory = factory - } - - // MARK: - UIKitRUMUserActionsHandlerType - - weak var subscriber: RUMCommandSubscriber? - - func publish(to subscriber: RUMCommandSubscriber) { - self.subscriber = subscriber - } - - func notify_sendEvent(application: UIApplication, event: UIEvent) { - guard let command = factory.command(from: event) else { - return // Not a "tap" event or doesn't have the view. - } - - guard let subscriber = subscriber else { - DD.logger.warn( - """ - RUM Action was detected, but no `RUMMonitor` is registered on `Global.rum`. RUM auto instrumentation will not work. - Make sure `Global.rum = RUMMonitor.initialize()` is called before any action happens. - """ - ) - return - } - - subscriber.process(command: command) - } -} - private extension UIView { /// Traverses the hierarchy of this view from bottom-up to find any parent view matching /// the given predicate. It starts from `self`. @@ -113,6 +65,7 @@ internal struct UITouchCommandFactory: UIEventCommandFactory { return RUMAddUserActionCommand( time: dateProvider.now, attributes: action.attributes, + instrumentation: .uikit, actionType: .tap, name: action.name ) @@ -167,6 +120,7 @@ internal struct UIPressCommandFactory: UIEventCommandFactory { return RUMAddUserActionCommand( time: dateProvider.now, attributes: action.attributes, + instrumentation: .uikit, actionType: .click, name: action.name ) diff --git a/DatadogRUM/Sources/Instrumentation/RUMCommandSubscriber.swift b/DatadogRUM/Sources/Instrumentation/RUMCommandSubscriber.swift index 2daf8ee1b4..135a71c5bf 100644 --- a/DatadogRUM/Sources/Instrumentation/RUMCommandSubscriber.swift +++ b/DatadogRUM/Sources/Instrumentation/RUMCommandSubscriber.swift @@ -27,3 +27,17 @@ internal protocol RUMCommandPublisher: AnyObject { /// - Parameter subscriber: The RUM command subscriber. func publish(to subscriber: RUMCommandSubscriber) } + +/// Represents the type of instrumentation used to create different RUM commands. +internal enum InstrumentationType: Int { + /// Command issued through UIKit-based instrumentation. + case uikit + /// Command issued through SwiftUI-based instrumentation. + case swiftui + /// Command issued through manual instrumentation, originating from the `RUMMonitor` API. + case manual + + /// The priority of this instrumentation. Higher values take precedence, allowing actions from one type to overwrite those + /// from a lower-priority type (e.g., a SwiftUI button tap takes precedence over the touch on its containing UIKit table view cell). + var priority: Int { rawValue } +} diff --git a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift index 3988617e39..3324111a3f 100644 --- a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift +++ b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift @@ -27,9 +27,9 @@ internal final class RUMInstrumentation: RUMCommandPublisher { /// Swizzles `UIApplication` for intercepting `UIEvents` passed to the app. /// It is `nil` (no swizzling) if RUM Action instrumentaiton is not enabled. let uiApplicationSwizzler: UIApplicationSwizzler? - /// Receives interceptions from `UIApplicationSwizzler`. - /// It is `nil` if RUM Action instrumentaiton is not enabled. - let actionsHandler: UIEventHandler? + /// Receives interceptions from `UIApplicationSwizzler` and from SwiftUI instrumentation. + /// It is non-optional as we can't know if SwiftUI instrumentation will be used or not. + let actionsHandler: RUMActionsHandling /// Instruments RUM Long Tasks. It is `nil` if long tasks tracking is not enabled. let longTasks: LongTaskObserver? @@ -61,41 +61,41 @@ internal final class RUMInstrumentation: RUMCommandPublisher { // Always create views handler (we can't know if it will be used by SwiftUI instrumentation) // and only swizzle `UIViewController` if UIKit instrumentation is configured: let viewsHandler = RUMViewsHandler(dateProvider: dateProvider, predicate: uiKitRUMViewsPredicate) - var viewControllerSwizzler: UIViewControllerSwizzler? = nil - - // Create actions handler and `UIApplicationSwizzler` only if UIKit instrumentation is configured: - var actionsHandler: UIKitRUMUserActionsHandler? = nil - var uiApplicationSwizzler: UIApplicationSwizzler? = nil + let viewControllerSwizzler: UIViewControllerSwizzler? = { + do { + if uiKitRUMViewsPredicate != nil { + return try UIViewControllerSwizzler(handler: viewsHandler) + } + } catch { + consolePrint( + "🔥 Datadog SDK error: UIKit RUM Views tracking can't be enabled due to error: \(error)", + .error + ) + } + return nil + }() + + // Always create actions handler (we can't know if it will be used by SwiftUI instrumentation) + // and only swizzle `UIApplicationSwizzler` if UIKit instrumentation is configured: + let actionsHandler = RUMActionsHandler(dateProvider: dateProvider, predicate: uiKitRUMActionsPredicate) + let uiApplicationSwizzler: UIApplicationSwizzler? = { + do { + if uiKitRUMActionsPredicate != nil { + return try UIApplicationSwizzler(handler: actionsHandler) + } + } catch { + consolePrint( + "🔥 Datadog SDK error: RUM Actions tracking can't be enabled due to error: \(error)", + .error + ) + } + return nil + }() // Create long tasks and app hang observers only if configured: var longTasks: LongTaskObserver? = nil var appHangs: AppHangsMonitor? = nil - do { - if uiKitRUMViewsPredicate != nil { - // UIKit views instrumentation is enabled, so install the swizzler: - viewControllerSwizzler = try UIViewControllerSwizzler(handler: viewsHandler) - } - } catch { - consolePrint( - "🔥 Datadog SDK error: UIKit RUM Views tracking can't be enabled due to error: \(error)", - .error - ) - } - - do { - if let predicate = uiKitRUMActionsPredicate { - let handler = UIKitRUMUserActionsHandler(dateProvider: dateProvider, predicate: predicate) - actionsHandler = handler - uiApplicationSwizzler = try UIApplicationSwizzler(handler: handler) - } - } catch { - consolePrint( - "🔥 Datadog SDK error: RUM Actions tracking can't be enabled due to error: \(error)", - .error - ) - } - if let longTaskThreshold = longTaskThreshold { if longTaskThreshold > Constants.minLongTaskThreshold { longTasks = LongTaskObserver(threshold: longTaskThreshold, dateProvider: dateProvider) @@ -150,7 +150,7 @@ internal final class RUMInstrumentation: RUMCommandPublisher { func publish(to subscriber: RUMCommandSubscriber) { viewsHandler.publish(to: subscriber) - actionsHandler?.publish(to: subscriber) + actionsHandler.publish(to: subscriber) longTasks?.publish(to: subscriber) appHangs?.nonFatalHangsHandler.publish(to: subscriber) memoryWarningMonitor?.reporter.publish(to: subscriber) diff --git a/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift b/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift index 770e4d1af8..6de74549ea 100644 --- a/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift +++ b/DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift @@ -142,12 +142,13 @@ internal final class RUMViewsHandler { private func start(view: View) { guard let subscriber = subscriber else { - return DD.logger.warn( + DD.logger.warn( """ - RUM View was started, but no `RUMMonitor` is registered on `Global.rum`. RUM instrumentation will not work. - Make sure `Global.rum = RUMMonitor.initialize()` is called before any view appears. + A RUM view was started with \(view.instrumentationType) instrumentation, but RUM tracking appears to be disabled. + Ensure `RUM.enable()` is called before starting any views. """ ) + return } guard !view.isUntrackedModal else { diff --git a/DatadogRUM/Sources/Instrumentation/Views/SwiftUI/SwiftUIViewModifier.swift b/DatadogRUM/Sources/Instrumentation/Views/SwiftUI/SwiftUIViewModifier.swift index a5ba1699a3..8ac67c073d 100644 --- a/DatadogRUM/Sources/Instrumentation/Views/SwiftUI/SwiftUIViewModifier.swift +++ b/DatadogRUM/Sources/Instrumentation/Views/SwiftUI/SwiftUIViewModifier.swift @@ -8,8 +8,8 @@ import SwiftUI import DatadogInternal -/// `SwiftUI.ViewModifier` for RUM which invoke `startView` and `stopView` from the -/// global RUM Monitor when the modified view appears and disappears. +/// `SwiftUI.ViewModifier` which notifes RUM instrumentation when modified view appears and disappears. +/// It makes an entry point to RUM views instrumentation in SwiftUI. @available(iOS 13, tvOS 13, *) internal struct RUMViewModifier: SwiftUI.ViewModifier { /// Datadog RUM instrumentation instance diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 34eb5372d9..e3ee7cc860 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -456,6 +456,7 @@ extension Monitor: RUMMonitorProtocol { command: RUMAddUserActionCommand( time: dateProvider.now, attributes: attributes, + instrumentation: .manual, actionType: type, name: name ) @@ -467,6 +468,7 @@ extension Monitor: RUMMonitorProtocol { command: RUMStartUserActionCommand( time: dateProvider.now, attributes: attributes, + instrumentation: .manual, actionType: type, name: name ) diff --git a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift index 8ba1cb1883..683dcc7573 100644 --- a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift +++ b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift @@ -431,6 +431,8 @@ internal struct RUMStartUserActionCommand: RUMUserActionCommand { var attributes: [AttributeKey: AttributeValue] let canStartBackgroundView = true // yes, we want to track actions in "Background" view (e.g. it makes sense for custom actions) let isUserInteraction = true // a user action definitely is a User Interacgion + /// The type of instrumentation used to create this command. + let instrumentation: InstrumentationType let actionType: RUMActionType let name: String @@ -455,6 +457,8 @@ internal struct RUMAddUserActionCommand: RUMUserActionCommand { var attributes: [AttributeKey: AttributeValue] let canStartBackgroundView = true // yes, we want to track actions in "Background" view (e.g. it makes sense for custom actions) let isUserInteraction = true // a user action definitely is a User Interacgion + /// The type of instrumentation used to create this command. + let instrumentation: InstrumentationType let actionType: RUMActionType let name: String diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift index 251fd661a4..0a722957cf 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift @@ -32,6 +32,8 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { /// This User Action's UUID. let actionUUID: RUMUUID + /// The type of instrumentation that issued an action that created this scope. + let instrumentation: InstrumentationType /// The start time of this User Action. private let actionStartTime: Date @@ -70,6 +72,7 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { startTime: Date, serverTimeOffset: TimeInterval, isContinuous: Bool, + instrumentation: InstrumentationType, onActionEventSent: @escaping (RUMActionEvent) -> Void ) { self.parent = parent @@ -82,6 +85,7 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { self.serverTimeOffset = serverTimeOffset self.isContinuous = isContinuous self.lastActivityTime = startTime + self.instrumentation = instrumentation self.onActionEventSent = onActionEventSent } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index ffa26d7bd6..9749797eb6 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -221,10 +221,14 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { if command.actionType == .custom { // send it instantly without waiting for child events (e.g. resource associated to this action) sendDiscreteCustomUserAction(on: command, context: context, writer: writer) - } else if userActionScope == nil { - addDiscreteUserAction(on: command) + } else if let actionScope = userActionScope { + if command.instrumentation.priority > actionScope.instrumentation.priority { + addDiscreteUserAction(on: command) + } else { + reportActionDropped(type: command.actionType, name: command.name) + } } else { - reportActionDropped(type: command.actionType, name: command.name) + addDiscreteUserAction(on: command) } // Error command @@ -316,6 +320,7 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { startTime: command.time, serverTimeOffset: serverTimeOffset, isContinuous: true, + instrumentation: command.instrumentation, onActionEventSent: { [weak self] event in self?.onActionEventSent(event) } @@ -332,6 +337,7 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { startTime: command.time, serverTimeOffset: serverTimeOffset, isContinuous: false, + instrumentation: command.instrumentation, onActionEventSent: { [weak self] event in self?.onActionEventSent(event) } diff --git a/DatadogRUM/Tests/Instrumentation/Actions/UIKitRUMUserActionsHandlerTests.swift b/DatadogRUM/Tests/Instrumentation/Actions/RUMActionsHandlerTests.swift similarity index 84% rename from DatadogRUM/Tests/Instrumentation/Actions/UIKitRUMUserActionsHandlerTests.swift rename to DatadogRUM/Tests/Instrumentation/Actions/RUMActionsHandlerTests.swift index 2cbec028ac..a0cb50a163 100644 --- a/DatadogRUM/Tests/Instrumentation/Actions/UIKitRUMUserActionsHandlerTests.swift +++ b/DatadogRUM/Tests/Instrumentation/Actions/RUMActionsHandlerTests.swift @@ -9,18 +9,18 @@ import TestUtilities import DatadogInternal @testable import DatadogRUM -class UIKitRUMUserActionsHandlerTests: XCTestCase { +class RUMActionsHandlerTests: XCTestCase { private let dateProvider = RelativeDateProvider(using: .mockDecember15th2019At10AMUTC()) private let commandSubscriber = RUMCommandSubscriberMock() - private func touchHandler(with predicate: UITouchRUMActionsPredicate = DefaultUIKitRUMActionsPredicate()) -> UIKitRUMUserActionsHandler { - let handler = UIKitRUMUserActionsHandler(dateProvider: dateProvider, predicate: predicate) + private func touchHandler(with predicate: UITouchRUMActionsPredicate = DefaultUIKitRUMActionsPredicate()) -> RUMActionsHandler { + let handler = RUMActionsHandler(dateProvider: dateProvider, predicate: predicate) handler.publish(to: commandSubscriber) return handler } - private func pressHandler(with predicate: UIPressRUMActionsPredicate = DefaultUIKitRUMActionsPredicate()) -> UIKitRUMUserActionsHandler { - let handler = UIKitRUMUserActionsHandler(dateProvider: dateProvider, predicate: predicate) + private func pressHandler(with predicate: UIPressRUMActionsPredicate = DefaultUIKitRUMActionsPredicate()) -> RUMActionsHandler { + let handler = RUMActionsHandler(dateProvider: dateProvider, predicate: predicate) handler.publish(to: commandSubscriber) return handler } @@ -39,7 +39,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { // MARK: - Scenarios For Accepting Tap Events - func testGivenViewWithAccessibilityIdentifier_whenSingleTouchEnds_itSendsRUMAction() { + func testGivenUIKitViewWithAccessibilityIdentifier_whenSingleTouchEnds_itSendsRUMAction() { // Given let handler = touchHandler() let fixtures: [(view: UIView, expectedRUMActionName: String)] = [ @@ -78,12 +78,13 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { let command = commandSubscriber.lastReceivedCommand as? RUMAddUserActionCommand XCTAssertEqual(command?.name, expectedRUMActionName) XCTAssertEqual(command?.actionType, .tap) + XCTAssertEqual(command?.instrumentation, .uikit) XCTAssertEqual(command?.time, .mockDecember15th2019At10AMUTC()) XCTAssertEqual(command?.attributes.count, 0) } } - func testGivenViewWithNoAccessibilityIdentifier_whenSingleTouchEnds_itSendsRUMAction() { + func testGivenUIKitViewWithNoAccessibilityIdentifier_whenSingleTouchEnds_itSendsRUMAction() { // Given let handler = touchHandler() let fixtures: [(view: UIView, expectedRUMActionName: String)] = [ @@ -115,6 +116,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { let command = commandSubscriber.lastReceivedCommand as? RUMAddUserActionCommand XCTAssertEqual(command?.name, expectedRUMActionName) XCTAssertEqual(command?.actionType, .tap) + XCTAssertEqual(command?.instrumentation, .uikit) XCTAssertEqual(command?.time, .mockDecember15th2019At10AMUTC()) XCTAssertEqual(command?.attributes.count, 0) } @@ -122,7 +124,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { // MARK: - Scenarios For Ignoring Tap Events - func testGivenAnyViewWithUnrecognizedHierarchy_whenTouchEnds_itGetsIgnored() { + func testGivenAnyUIKitViewWithUnrecognizedHierarchy_whenTouchEnds_itGetsIgnored() { // Given let handler = touchHandler() let superview = UIView().attached(to: mockAppWindow) @@ -138,7 +140,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testGivenAnyViewPresentedInKeyboardWindow_whenTouchEnds_itGetsIgnoredForPrivacyReason() { + func testGivenAnyUIKitViewPresentedInKeyboardWindow_whenTouchEnds_itGetsIgnoredForPrivacyReason() { let mockKeyboardWindow = MockUIRemoteKeyboardWindow(frame: .zero) // Given @@ -170,7 +172,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testItIgnoresSingleTouchEventWithPhaseOtherThanEnded() { + func testItIgnoresSingleUIKitTouchEventWithPhaseOtherThanEnded() { // Given let handler = touchHandler() let view = UIControl().attached(to: mockAppWindow) @@ -194,7 +196,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { } } - func testItIgnoresMultitouchEvents() { + func testItIgnoresUIKitMultitouchEvents() { // Given let handler = touchHandler() let view = UIControl().attached(to: mockAppWindow) @@ -214,7 +216,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testItIgnoresEventsWithNoTouch() { + func testItIgnoresUIKitEventsWithNoTouch() { // Given let handler = touchHandler() @@ -228,7 +230,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testGivenTouchEvent_itAppliesUserAttributesAndCustomName() { + func testGivenUIKitTouchEvent_itAppliesUserAttributesAndCustomName() { // Given let mockAttributes: [AttributeKey: AttributeValue] = mockRandomAttributes() let handler = touchHandler( @@ -253,7 +255,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { DDAssertDictionariesEqual(command!.attributes, mockAttributes) } - func testGivenUserActionPredicateReturnsNil_itDoesntSendTapAction() { + func testGivenUIKitActionPredicateReturnsNil_itDoesntSendTapAction() { // Given let handler = touchHandler( with: MockUIKitRUMActionsPredicate(actionOverride: nil) @@ -275,7 +277,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { // MARK: - Scenarios For Accepting Click Events - func testGivenUIPress_whenSinglePressEnds_itSendsRUMAction() { + func testGivenUIKitPressEvent_whenSinglePressEnds_itSendsRUMAction() { // Given let handler = pressHandler() let fixtures: [(event: UIEvent, expect: String)] = [ @@ -308,6 +310,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { let command = commandSubscriber.lastReceivedCommand as? RUMAddUserActionCommand XCTAssertEqual(command?.name, expect) XCTAssertEqual(command?.actionType, .click) + XCTAssertEqual(command?.instrumentation, .uikit) XCTAssertEqual(command?.time, .mockDecember15th2019At10AMUTC()) XCTAssertEqual(command?.attributes.count, 0) } @@ -315,7 +318,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { // MARK: - Scenarios For Ignoring Tap Events - func testGivenAnyViewPresentedInKeyboardWindow_whenPressEnds_itGetsIgnoredForPrivacyReason() { + func testGivenAnyUIKitViewPresentedInKeyboardWindow_whenPressEnds_itGetsIgnoredForPrivacyReason() { let mockKeyboardWindow = MockUIRemoteKeyboardWindow(frame: .zero) // Given @@ -347,7 +350,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testItIgnoresSinglePressEventWithPhaseOtherThanEnded() { + func testItIgnoresSingleUIKitPressEventWithPhaseOtherThanEnded() { // Given let handler = pressHandler() let view = UIControl().attached(to: mockAppWindow) @@ -366,7 +369,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { } } - func testItIgnoresMultiPressEvents() { + func testItIgnoresUIKitMultiPressEvents() { // Given let handler = pressHandler() let view = UIControl().attached(to: mockAppWindow) @@ -386,7 +389,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { XCTAssertNil(commandSubscriber.lastReceivedCommand) } - func testGivenPressEvent_ItAppliesUserAttributesAndCustomName() { + func testGivenUIKitPressEvent_ItAppliesUserAttributesAndCustomName() { // Given let mockAttributes: [AttributeKey: AttributeValue] = mockRandomAttributes() let handler = pressHandler( @@ -411,7 +414,7 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { DDAssertDictionariesEqual(command!.attributes, mockAttributes) } - func testGivenUserActionPredicateReturnsNil_itDoesntSendClickAction() { + func testGivenUIKitActionPredicateReturnsNil_itDoesntSendClickAction() { // Given let handler = pressHandler( with: MockUIKitRUMActionsPredicate(actionOverride: nil) @@ -430,6 +433,29 @@ class UIKitRUMUserActionsHandlerTests: XCTestCase { // Then XCTAssertNil(commandSubscriber.lastReceivedCommand) } + + // MARK: - SwiftUI Actions + + func testWhenSwiftUIViewModifierIsTapped_itSendsRUMAction() throws { + // Given + let handler = oneOf([ + { self.touchHandler() }, + { self.pressHandler() } + ]) + + // When + let actionName: String = .mockRandom() + let actionAttributes = mockRandomAttributes() + handler.notify_viewModifierTapped(actionName: actionName, actionAttributes: actionAttributes) + + // Then + let command = try XCTUnwrap(commandSubscriber.lastReceivedCommand as? RUMAddUserActionCommand) + XCTAssertEqual(command.name, actionName) + XCTAssertEqual(command.actionType, .tap) + XCTAssertEqual(command.instrumentation, .swiftui) + XCTAssertEqual(command.time, .mockDecember15th2019At10AMUTC()) + DDAssertReflectionEqual(command.attributes, actionAttributes) + } } // MARK: - Helpers diff --git a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift index ac9e4c65d1..844c6a32c8 100644 --- a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift +++ b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift @@ -183,7 +183,7 @@ class RUMInstrumentationTests: XCTestCase { // Then withExtendedLifetime(instrumentation) { XCTAssertIdentical(instrumentation.viewsHandler.subscriber, subscriber) - XCTAssertIdentical((instrumentation.actionsHandler as? UIKitRUMUserActionsHandler)?.subscriber, subscriber) + XCTAssertIdentical((instrumentation.actionsHandler as? RUMActionsHandler)?.subscriber, subscriber) XCTAssertIdentical(instrumentation.longTasks?.subscriber, subscriber) XCTAssertIdentical(instrumentation.appHangs?.nonFatalHangsHandler.subscriber, subscriber) } diff --git a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift index 6a86a4f11e..5f7bef6676 100644 --- a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift +++ b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift @@ -567,11 +567,12 @@ extension RUMStartUserActionCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], + instrumentation: InstrumentationType = .manual, actionType: RUMActionType = .swipe, name: String = .mockAny() ) -> RUMStartUserActionCommand { return RUMStartUserActionCommand( - time: time, attributes: attributes, actionType: actionType, name: name + time: time, attributes: attributes, instrumentation: instrumentation, actionType: actionType, name: name ) } } @@ -615,11 +616,12 @@ extension RUMAddUserActionCommand: AnyMockable, RandomMockable { static func mockWith( time: Date = Date(), attributes: [AttributeKey: AttributeValue] = [:], + instrumentation: InstrumentationType = .manual, actionType: RUMActionType = .tap, name: String = .mockAny() ) -> RUMAddUserActionCommand { return RUMAddUserActionCommand( - time: time, attributes: attributes, actionType: actionType, name: name + time: time, attributes: attributes, instrumentation: instrumentation, actionType: actionType, name: name ) } } @@ -1007,6 +1009,7 @@ extension RUMUserActionScope { startTime: Date = .mockAny(), serverTimeOffset: TimeInterval = .zero, isContinuous: Bool = .mockAny(), + instrumentation: InstrumentationType = .manual, onActionEventSent: @escaping (RUMActionEvent) -> Void = { _ in } ) -> RUMUserActionScope { return RUMUserActionScope( @@ -1018,6 +1021,7 @@ extension RUMUserActionScope { startTime: startTime, serverTimeOffset: serverTimeOffset, isContinuous: isContinuous, + instrumentation: instrumentation, onActionEventSent: onActionEventSent ) } diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMViewScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMViewScopeTests.swift index 33bd8a0ecb..5570197d02 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMViewScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMViewScopeTests.swift @@ -1278,6 +1278,91 @@ class RUMViewScopeTests: XCTestCase { XCTAssertEqual(event.view.frustration?.count, 5) } + func testWhenTwoTapActionsTrackedSequentially_thenHigherPriorityInstrumentationWins() throws { + func actionName(for instrumentationType: InstrumentationType) -> String { + switch instrumentationType { + case .manual: return "Manual action" + case .uikit: return "UIKit action" + case .swiftui: return "SwiftUI action" + } + } + + /// Simulates two consecutive tap actions, triggered by different instrumentation types, + /// and asserts that the higher priority action is tracked. + /// - Parameters: + /// - firstTap: The type of instrumentation that tracks the first tap. + /// - secondTap: The type of instrumentation that tracks the second tap. + /// - expectedActionName: The expected action name after the second tap is processed. + func testTapActions( + firstTap: InstrumentationType, secondTap: InstrumentationType, expectedActionName: String + ) throws { + let firstActionName = actionName(for: firstTap) + let secondActionName = actionName(for: secondTap) + + var currentTime = Date() + let scope = RUMViewScope( + isInitialView: false, + parent: parent, + dependencies: .mockAny(), + identity: .mockViewIdentifier(), + path: .mockAny(), + name: .mockAny(), + attributes: [:], + customTimings: [:], + startTime: currentTime, + serverTimeOffset: .zero + ) + _ = scope.process( + command: RUMStartViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: context, + writer: writer + ) + + // Given: The first tap action is tracked + _ = scope.process( + command: RUMAddUserActionCommand.mockWith( + time: currentTime, instrumentation: firstTap, actionType: .tap, name: firstActionName + ), + context: context, + writer: writer + ) + + // When: The second tap action is tracked shortly after + currentTime.addTimeInterval(.mockRandom(min: 0, max: RUMUserActionScope.Constants.discreteActionTimeoutDuration)) + _ = scope.process( + command: RUMAddUserActionCommand.mockWith( + time: currentTime, instrumentation: secondTap, actionType: .tap, name: secondActionName + ), + context: context, + writer: writer + ) + + // Then: Assert that the higher-priority action is the one being tracked + currentTime.addTimeInterval(RUMUserActionScope.Constants.discreteActionTimeoutDuration) + _ = scope.process( + command: RUMStopViewCommand.mockWith(time: currentTime, identity: .mockViewIdentifier()), + context: context, + writer: writer + ) + + let viewEvent = try XCTUnwrap(writer.events(ofType: RUMViewEvent.self).last) + let actionName = try XCTUnwrap(writer.events(ofType: RUMActionEvent.self).last?.action.target?.name) + XCTAssertEqual(viewEvent.view.action.count, 1) + XCTAssertEqual( + actionName, + expectedActionName, + "When \(firstActionName) is followed by \(secondActionName) it should sent \(expectedActionName) not \(actionName)" + ) + } + + try testTapActions(firstTap: .uikit, secondTap: .swiftui, expectedActionName: actionName(for: .swiftui)) + try testTapActions(firstTap: .uikit, secondTap: .manual, expectedActionName: actionName(for: .manual)) + try testTapActions(firstTap: .swiftui, secondTap: .manual, expectedActionName: actionName(for: .manual)) + try testTapActions(firstTap: .manual, secondTap: .uikit, expectedActionName: actionName(for: .manual)) + try testTapActions(firstTap: .manual, secondTap: .swiftui, expectedActionName: actionName(for: .manual)) + try testTapActions(firstTap: .swiftui, secondTap: .uikit, expectedActionName: actionName(for: .swiftui)) + } + // MARK: - Error Tracking func testWhenViewErrorIsAdded_itSendsErrorEventAndViewUpdateEvent() throws { diff --git a/DatadogRUM/Tests/RUMTests.swift b/DatadogRUM/Tests/RUMTests.swift index 2d79e8e014..4852079fe3 100644 --- a/DatadogRUM/Tests/RUMTests.swift +++ b/DatadogRUM/Tests/RUMTests.swift @@ -110,7 +110,7 @@ class RUMTests: XCTestCase { let rum = try XCTUnwrap(core.get(feature: RUMFeature.self)) let monitor = try XCTUnwrap(RUMMonitor.shared(in: core) as? Monitor) XCTAssertIdentical(monitor, rum.instrumentation.viewsHandler.subscriber) - XCTAssertIdentical(monitor, (rum.instrumentation.actionsHandler as? UIKitRUMUserActionsHandler)?.subscriber) + XCTAssertIdentical(monitor, (rum.instrumentation.actionsHandler as? RUMActionsHandler)?.subscriber) XCTAssertIdentical(monitor, rum.instrumentation.longTasks?.subscriber) XCTAssertIdentical(monitor, rum.instrumentation.appHangs?.nonFatalHangsHandler.subscriber) } @@ -131,9 +131,13 @@ class RUMTests: XCTestCase { XCTAssertIdentical( monitor, rum.instrumentation.viewsHandler.subscriber, - "It must always subscribe RUM monitor to `RUMViewsHandler` as it is required for manual SwiftUI instrumentation" + "It must always subscribe RUM monitor to `RUMViewsHandler` as it is required for SwiftUI instrumentation" + ) + XCTAssertIdentical( + monitor, + (rum.instrumentation.actionsHandler as? RUMActionsHandler)?.subscriber, + "It must always subscribe RUM monitor to `RUMActionsHandler` as it is required for SwiftUI instrumentation" ) - XCTAssertNil(rum.instrumentation.actionsHandler) XCTAssertNil(rum.instrumentation.longTasks) XCTAssertNil(rum.instrumentation.appHangs) } From 922bba32b92430e04d650f493c6ab58e4c1e6f72 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Tue, 15 Oct 2024 15:18:45 +0200 Subject: [PATCH 18/43] RUM-6376 Address CR comments --- Datadog/Datadog.xcodeproj/project.pbxproj | 16 +++---- DatadogInternal/Sources/CoreRegistry.swift | 13 ++++++ DatadogInternal/Tests/CoreRegistryTest.swift | 42 ++++++++++++++++++- .../Sources/SessionReplay.swift | 4 +- .../Tests/SessionReplayTests.swift | 2 + .../Mocks/CoreMocks}/MockFeature.swift | 3 -- 6 files changed, 67 insertions(+), 13 deletions(-) rename {DatadogSessionReplay/Tests/Mocks => TestUtilities/Mocks/CoreMocks}/MockFeature.swift (94%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index a96f953f40..c89beb3101 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -383,8 +383,6 @@ 6147E3B3270486920092BC9F /* TraceConfigurationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6147E3B2270486920092BC9F /* TraceConfigurationE2ETests.swift */; }; 614A708E2BF754D800D9AF42 /* ImmutableRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614A708D2BF754D700D9AF42 /* ImmutableRequest.swift */; }; 614A708F2BF754D800D9AF42 /* ImmutableRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614A708D2BF754D700D9AF42 /* ImmutableRequest.swift */; }; - 614B78ED296D7B63009C6B92 /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; - 614B78EE296D7B63009C6B92 /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 614B78F1296D7B63009C6B92 /* LowPowerModePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EC296D7B63009C6B92 /* LowPowerModePublisherTests.swift */; }; 614B78F2296D7B63009C6B92 /* LowPowerModePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EC296D7B63009C6B92 /* LowPowerModePublisherTests.swift */; }; 614CADD72510BAC000B93D2D /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614CADD62510BAC000B93D2D /* Environment.swift */; }; @@ -685,6 +683,10 @@ 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */; }; 96E414162C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414152C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift */; }; + 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; + 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; + 96F69D6E2CBE94F500A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; + 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -700,7 +702,6 @@ A70A82662A935F210072F5DC /* BackgroundTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70A82642A935F210072F5DC /* BackgroundTaskCoordinator.swift */; }; A70ADCD22B583B1300321BC9 /* UIImageResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70ADCD12B583B1300321BC9 /* UIImageResource.swift */; }; A71013D62B178FAD00101E60 /* ResourcesWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71013D52B178FAD00101E60 /* ResourcesWriterTests.swift */; }; - A71265862B17980C007D63CE /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; A727C4BB2BADB3AB00707DFD /* DDSessionReplay+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A795069D2B974CAA00AC4814 /* DDSessionReplay+apiTests.m */; }; 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 */; }; @@ -3994,7 +3995,6 @@ 61054F872A6EE1BA00AAA894 /* RUMContextObserverMock.swift */, A74A72862B10CE4100771FEB /* ResourceMocks.swift */, A74A72882B10D95D00771FEB /* MultipartBuilderSpy.swift */, - A71265852B17980C007D63CE /* MockFeature.swift */, A7D952892B28BD94004C79B1 /* ResourceProcessorSpy.swift */, ); path = Mocks; @@ -5352,6 +5352,7 @@ 61C713C52A3CA08B00FA735A /* CoreMocks */ = { isa = PBXGroup; children = ( + A71265852B17980C007D63CE /* MockFeature.swift */, D257954A298ABB04008A1BE5 /* PassthroughCoreMock.swift */, D2160CEF29C0EC4D00FAA9A5 /* SingleFeatureCoreMock.swift */, 61C713CF2A3DEFF900FA735A /* FeatureRegistrationCoreMock.swift */, @@ -8186,6 +8187,7 @@ E143CCAF27D236F600F4018A /* CITestIntegrationTests.swift in Sources */, 6128F57E2BA8A3A000D35B08 /* DataStore+TLVTests.swift in Sources */, D224430D29E95D6700274EC7 /* CrashReportReceiverTests.swift in Sources */, + 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */, D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */, D21831552B6A57530012B3A0 /* NetworkInstrumentationIntegrationTests.swift in Sources */, 61D3E0E4277B3D92008BE766 /* KronosNTPPacketTests.swift in Sources */, @@ -8231,7 +8233,6 @@ 3C1890152ABDE9BF00CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m in Sources */, D28F836529C9E69E00EF8EA2 /* DatadogTraceFeatureTests.swift in Sources */, 61133C4B2423990D00786299 /* DDLogsTests.swift in Sources */, - 614B78ED296D7B63009C6B92 /* DatadogCoreTests.swift in Sources */, 61C5A89624509BF600DA608C /* TracerTests.swift in Sources */, D22743E929DEC9A9001A7EF9 /* RUMDataModelMocks.swift in Sources */, 61F1A61A2498A51700075390 /* CoreMocks.swift in Sources */, @@ -8458,7 +8459,6 @@ 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 /* RecordsWriterTests.swift in Sources */, 61054FB82A6EE1BA00AAA894 /* UIDatePickerRecorderTests.swift in Sources */, @@ -8969,6 +8969,7 @@ 3C85D42C29F7C87D00AFF894 /* HostsSanitizerMock.swift in Sources */, D2160CF729C0EE2B00FAA9A5 /* UploadMocks.swift in Sources */, D2F44FBC299AA36D0074B0D9 /* Decompression.swift in Sources */, + 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */, D24C9C5229A7BD12002057CF /* SamplerMock.swift in Sources */, D2579557298ABB04008A1BE5 /* AttributesMocks.swift in Sources */, D2C9A2872C0F467C007526F5 /* SessionReplayConfigurationMocks.swift in Sources */, @@ -9016,6 +9017,7 @@ 3C85D42D29F7C87D00AFF894 /* HostsSanitizerMock.swift in Sources */, D2160CF829C0EE2B00FAA9A5 /* UploadMocks.swift in Sources */, D2F44FBD299AA36D0074B0D9 /* Decompression.swift in Sources */, + 96F69D6E2CBE94F500A6178B /* MockFeature.swift in Sources */, D24C9C5329A7BD12002057CF /* SamplerMock.swift in Sources */, D257957F298ABB83008A1BE5 /* AttributesMocks.swift in Sources */, D2C9A2882C0F467C007526F5 /* SessionReplayConfigurationMocks.swift in Sources */, @@ -9468,7 +9470,6 @@ D22743DE29DEB8B5001A7EF9 /* VitalInfoSamplerTests.swift in Sources */, D2CB6F0927C520D400A62B57 /* RUMDataModels+objcTests.swift in Sources */, D234613128B7713000055D4C /* FeatureContextTests.swift in Sources */, - 614B78EE296D7B63009C6B92 /* DatadogCoreTests.swift in Sources */, D2CB6F0C27C520D400A62B57 /* KronosNTPPacketTests.swift in Sources */, D2CB6F0E27C520D400A62B57 /* DDRUMMonitorTests.swift in Sources */, 612C13D12AA772FA0086B5D1 /* SRRequestMatcher.swift in Sources */, @@ -9478,6 +9479,7 @@ 6167E7072B82A9FD00C3CA2D /* GeneratingBacktraceTests.swift in Sources */, D2CB6F1327C520D400A62B57 /* DDConfigurationTests.swift in Sources */, D2CB6F1727C520D400A62B57 /* ObjcExceptionHandlerTests.swift in Sources */, + 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */, D28F836B29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift in Sources */, D2CB6F1827C520D400A62B57 /* DatadogTestsObserver.swift in Sources */, 61F3E36E2BC7D66700C7881E /* HeadBasedSamplingTests.swift in Sources */, diff --git a/DatadogInternal/Sources/CoreRegistry.swift b/DatadogInternal/Sources/CoreRegistry.swift index f3c917038a..54d0b771e6 100644 --- a/DatadogInternal/Sources/CoreRegistry.swift +++ b/DatadogInternal/Sources/CoreRegistry.swift @@ -76,4 +76,17 @@ public final class CoreRegistry { public static func instance(named name: String) -> DatadogCoreProtocol { instances[name] ?? NOPDatadogCore() } + + /// Checks if the specified `DatadogFeature` is enabled for any registered core instance. + /// + /// - Parameter feature: The feature type to check for. + /// - Returns: `true` if the feature is enabled in at least one instance, otherwise `false`. + public static func isFeatureEnabled(feature: T.Type) -> Bool where T: DatadogFeature { + for instance in instances.values { + if instance.get(feature: T.self) != nil { + return true + } + } + return false + } } diff --git a/DatadogInternal/Tests/CoreRegistryTest.swift b/DatadogInternal/Tests/CoreRegistryTest.swift index fc57718202..aed89048bc 100644 --- a/DatadogInternal/Tests/CoreRegistryTest.swift +++ b/DatadogInternal/Tests/CoreRegistryTest.swift @@ -5,7 +5,7 @@ */ import XCTest -import TestUtilities +@testable import TestUtilities @testable import DatadogInternal @@ -46,4 +46,44 @@ class CoreRegistryTest: XCTestCase { CoreRegistry.unregisterDefault() CoreRegistry.unregisterInstance(named: "test") } + + func testIsFeatureEnabled_whenFeatureIsRegistered_itReturnsTrue() { + // Given + let core = FeatureRegistrationCoreMock() + let feature = MockFeature() + + // Register the mock feature in the core + try? core.register(feature: feature) + + // Register the core in the CoreRegistry + CoreRegistry.register(default: core) + + // When + let isEnabled = CoreRegistry.isFeatureEnabled(feature: MockFeature.self) + + // Then + XCTAssertTrue(isEnabled) + + // Cleanup + CoreRegistry.unregisterDefault() + } + + func testIsFeatureEnabled_whenFeatureIsNotRegistered_itReturnsFalse() { + // Given + let core = FeatureRegistrationCoreMock() + + // No feature registered + + // Register the core in the CoreRegistry + CoreRegistry.register(default: core) + + // When + let isEnabled = CoreRegistry.isFeatureEnabled(feature: MockFeature.self) + + // Then + XCTAssertFalse(isEnabled) + + // Cleanup + CoreRegistry.unregisterDefault() + } } diff --git a/DatadogSessionReplay/Sources/SessionReplay.swift b/DatadogSessionReplay/Sources/SessionReplay.swift index 3f60e9f04f..df662f6ab5 100644 --- a/DatadogSessionReplay/Sources/SessionReplay.swift +++ b/DatadogSessionReplay/Sources/SessionReplay.swift @@ -70,8 +70,8 @@ public enum SessionReplay { ) } - guard core.get(feature: SessionReplayFeature.self) == nil else { - core.telemetry.send(telemetry: .debug(id: "1", message: "Session Replay has already been enabled", attributes: nil)) + guard !CoreRegistry.isFeatureEnabled(feature: SessionReplayFeature.self) else { + core.telemetry.debug("Session Replay has already been enabled") throw ProgrammerError( description: "Session Replay is already enabled and does not support multiple instances. The existing instance will continue to be used." ) diff --git a/DatadogSessionReplay/Tests/SessionReplayTests.swift b/DatadogSessionReplay/Tests/SessionReplayTests.swift index 3788630129..62d89f8f2f 100644 --- a/DatadogSessionReplay/Tests/SessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayTests.swift @@ -17,12 +17,14 @@ class SessionReplayTests: XCTestCase { override func setUpWithError() throws { core = FeatureRegistrationCoreMock() + CoreRegistry.register(default: core) config = SessionReplay.Configuration(replaySampleRate: 100) } override func tearDown() { core = nil config = nil + CoreRegistry.unregisterDefault() XCTAssertEqual(FeatureRegistrationCoreMock.referenceCount, 0) } diff --git a/DatadogSessionReplay/Tests/Mocks/MockFeature.swift b/TestUtilities/Mocks/CoreMocks/MockFeature.swift similarity index 94% rename from DatadogSessionReplay/Tests/Mocks/MockFeature.swift rename to TestUtilities/Mocks/CoreMocks/MockFeature.swift index af21d7b7cf..ae62007aeb 100644 --- a/DatadogSessionReplay/Tests/Mocks/MockFeature.swift +++ b/TestUtilities/Mocks/CoreMocks/MockFeature.swift @@ -4,10 +4,8 @@ * Copyright 2019-Present Datadog, Inc. */ -#if os(iOS) import Foundation import DatadogInternal -import DatadogSessionReplay internal class MockFeature: DatadogRemoteFeature { static var name = "mock-feature" @@ -21,4 +19,3 @@ internal class MockRequestBuilder: FeatureRequestBuilder { URLRequest.mockAny() } } -#endif From d1b27668510933088688b6b11047bf5c3d2dc431 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Fri, 27 Sep 2024 16:57:56 +0200 Subject: [PATCH 19/43] Create sdk_performance.md --- docs/sdk_performance.md | 204 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/sdk_performance.md diff --git a/docs/sdk_performance.md b/docs/sdk_performance.md new file mode 100644 index 0000000000..28b5e6f80e --- /dev/null +++ b/docs/sdk_performance.md @@ -0,0 +1,204 @@ +# SDK Performance and impact on the host application + +## Methodology + +Following benchmarks were collected by running Datadog iOS SDK ([fe86f81](https://github.com/DataDog/dd-sdk-ios/commit/fe86f8151e0a7932bb397f98cb166a9c81f5dac9)) in open source application: [Beam](https://github.com/awkward/beam). Performance data was recorded with Xcode 14.3 (14E222b) Debug Navigator and network traffic was recorded with [Charles Proxy 4.6.4](https://www.charlesproxy.com/download/latest-release/) installed on macOS. + +**3** **configurations** were measured: + +- running app with SDK not initialised; + +- running app with SDK initialised and data collection enabled (`trackingConsent: .granted`); + +- running app with SDK initialised but data collection disabled (`trackingConsent: .notGranted`). + +**Application info:** + +[Beam](https://github.com/awkward/beam) is an open source Reddit client, available on the App Store (see [Beam for reddit](https://apps.apple.com/us/app/beam-for-reddit/id937987469)). All tests were performed for [759623f](https://github.com/awkward/beam/commit/759623fae6df021d9a04ab5ef63cb6520029fba6) commit using `Release` configuration with debugger attached. + +**iOS Device info:** + +All data was collected on iPhone 13 (MLPF3F/A), running iOS 16.4.1 (a) with 42,54GB of available memory (out of 128GB). This device had 147 apps installed, but no other application was running during tests. Device was connected to LTE network and WIFI interface. + +## Results + +Rows represent individual test runs and columns represent each scenario: + +- “SDK not initialised” - SDK is installed, but not initialised. + +- “Consent granted” - SDK is initialized with data collection enabled (tracking consent: `.granted`). + +- “Consent not granted” - SDK is initialized with data collection disabled (tracking consent: `.notGranted`). + +### Max CPU (in %) + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|41%|46%|42%| +|#2|37%|51%|39%| +|#3|42%|45%|47%| +|#4|36%|42%|38%| +|#5|44%|37%|36%| + +no significant difference in CPU pick + +### Max RAM (in MBs) + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|70.7 MB|78.6 MB|88.2 MB| +|#2|64.9 MB|77 MB|76.9 MB| +|#3|66.8 MB|75.2 MB|68.3 MB| +|#4|70.8MB|58.5 MB|71.8 MB| +|#5|66.6MB|72.7 MB|77.3 MB| + + +no significant difference in RAM pick + +### High CPU Utilization (% of periods with CPU utilization higher than 20%) + +CPU usage of greater than 20%. High CPU utilization rapidly drains a device’s battery. + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|2.7%|3.3%|3.3%| +|#2|3.3%|3.1%|3.4%| +|#3|2.9%|3.2%|3.8%| +|#4|2.9%|3.1%|3.3%| +|#5|3%|2.8%|3.4%| + +no significant difference in high CPU utilization + +### Battery Utilization - Overhead + +Overhead represents energy use as a result of bringing up radios and other system resources the app needs to perform work. + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|39.2%|46.7%|36.9%| +|#2|39.7%|44.1%|38%| +|#3|41%|45.2%|35.3%| +|#4|40.6%|43.5%|37.9%| +|#5|40.1%|44%|36.7%| + +We see a minor decrease of overhead when the SDK is not initialized or without consent but it is not significant enough to measure an impact + +### Network Utilization (Uploads / Downloads) + +This table includes data sent and received by Datadog SDK only. + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|n/a|U: 21.85 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| +|#2|n/a|U: 21.98 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| +|#3|n/a|U: 21.71 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| +|#4|n/a|U: 21.86 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| +|#5|n/a|U: 22.02 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| + +no increase in energy use due to networking + +### Disk Usage (Reads / Writes) + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|R: 31.4 MB / W: 44.4 MB|R: 54.5 MB / W: 48.5 MB|R: 65.9 MB / W: 49.9 MB| +|#2|R: 27.9 MB / W: 44.2 MB|R: 35 MB / W: 48.6 MB|R: 26.3 MB / W: 47.4 MB| +|#3|R: 33.5 MB / W: 40.9 MB|R: 35.5 MB / W: 49.2 MB|R: 31.9 MB / W: 44.4 MB| +|#4|R: 32.5 MB / W: 43.1 MB|R: 30.6 MB / W: 45.4 MB|R: 34.5 MB / W: 43.1 MB| +|#5|R: 29.4 MB / W: 43.9 MB|R: 33.1 MB / W: 45.8 MB|R: 32.2 MB MB / W: 50.5 MB| + +no significant impact on disk usage. + +### Application Launch Time (cold start in seconds) + +The application launch time is the time interval between the application process start and the [UIApplicationDidBecomeActiveNotification](https://developer.apple.com/documentation/uikit/uiapplicationdidbecomeactivenotification). + +|Run|SDK not initialized|Consent granted|Consent not granted| +|--- |--- |--- |--- | +|#1|0.718|0.764|0.410| +|#2|0.696|0.483|0.388| +|#3|1.317|1.255|1.222| +|#4|0.607|0.413|0.536| +|#5|1.132|0.331|0.920| + +no significant difference in application launch time + +### Bundle Size + +||without Datadog SDK|with Datadog SDK| +|--- |--- |--- | +|Size of the `.ipa` file|22.2 MB|23.6 MB| + +## Appendix + +**Test scenario:** + +The test scenario was designed to simulate typical app usage (browsing reddits, user profiles and their comments). To eliminate external factors and noise (like another process being woken up or the app executing slightly different logic) the scenario was run multiple times: + +- 5 times for each of 3 configurations to record performance data with Xcode Debug Navigator, + +- 5 times for “SDK initialised and data collection enabled” to record network traffic with Charles Proxy, + +- 1 time for “SDK not initialised” and “data collection disabled” scenarios to record network traffic. + +Each test run took exactly `2min 30s`: + +1. Install the app on device. + +2. Launch the app, dismiss onboarding screen (_“Explore without account”_) and push notifications alert. + +3. `20s`: Wait. + +4. `30s`: Refresh reddits list on the Subreddits screen → go to “Art” reddit → load Top items for past month → go to 3rd reddit → load Top comments → go to the first user profile → load their Comments → go back to the main screen → go to Search tab → search for “food” → enter some result from the top of the list → go back to the main screen. + +5. `20s`: Wait. + +6. `30s`: Repeat step 4th with browsing “DYI” reddit and searching for “soccer”. + +7. `50s`: Wait. + +8. Pause the App process and record measurements. + +**Measurements:** + +All performance results were collected using Xcode 14.3 Debug Navigator. The app process was paused `2min 30s` after launch and its state was dumped by taking screenshots of navigator sections: CPU, Memory, Energy, Disk, Network and GPU. + +Network traffic was recorded with Charles Proxy 4.6.4\. The proxy was enabled before each run and disabled after `2min 30s`. + +**Instrumentation:** + +The SDK was initialized with basic RUM instrumentation for tracking views, actions and resources: + +```swift +Datadog.initialize( + appContext: .init(), + trackingConsent: trackingConsent, + configuration: .builderUsing( + rumApplicationID: applicationID, + clientToken: clientToken, + environment: "benchmark" + ) + .enableLogging(true) + .enableRUM(true) + .enableTracing(true) + .trackUIKitRUMViews() + .trackUIKitRUMActions() + .trackURLSession() + .set(rumSessionsSamplingRate: 100) + .set(loggingSamplingRate: 100) + .set(loggingSamplingRate: 100) + .set(customRUMEndpoint: URL(string: "http://172.16.10.112:8000/rum")!) + .set(customLogsEndpoint: URL(string: "http://172.16.10.112:8000/logs")!) + .build() +) + +Datadog.verbosityLevel = .debug + +DatadogTracer.initialize( + configuration: .init(customIntakeURL: URL(string: "http://172.16.10.112:8000/span")!) +) + +logger = DatadogLogger.builder.build() +``` + +The `DDURLSessionDelegate` was installed in session instances created in: `ImgurController`, `AuthenticationController`, `DataRequest` and `RedditUserRequest`. From 698d194fa47af54d0573521d63fdbaa49d9fe176 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Wed, 16 Oct 2024 13:46:32 +0200 Subject: [PATCH 20/43] Apply suggestions from code review Co-authored-by: May Lee Co-authored-by: Marie Denis <29802155+mariedm@users.noreply.github.com> --- docs/sdk_performance.md | 54 ++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/sdk_performance.md b/docs/sdk_performance.md index 28b5e6f80e..f23f729303 100644 --- a/docs/sdk_performance.md +++ b/docs/sdk_performance.md @@ -2,29 +2,29 @@ ## Methodology -Following benchmarks were collected by running Datadog iOS SDK ([fe86f81](https://github.com/DataDog/dd-sdk-ios/commit/fe86f8151e0a7932bb397f98cb166a9c81f5dac9)) in open source application: [Beam](https://github.com/awkward/beam). Performance data was recorded with Xcode 14.3 (14E222b) Debug Navigator and network traffic was recorded with [Charles Proxy 4.6.4](https://www.charlesproxy.com/download/latest-release/) installed on macOS. +The following benchmarks were collected by running Datadog iOS SDK ([fe86f81](https://github.com/DataDog/dd-sdk-ios/commit/fe86f8151e0a7932bb397f98cb166a9c81f5dac9)) in open source application: [Beam](https://github.com/awkward/beam). Performance data was recorded with Xcode 14.3 (14E222b) Debug Navigator and network traffic was recorded with [Charles Proxy 4.6.4](https://www.charlesproxy.com/download/latest-release/) installed on macOS. **3** **configurations** were measured: -- running app with SDK not initialised; +- running app with SDK not initialized; -- running app with SDK initialised and data collection enabled (`trackingConsent: .granted`); +- running app with SDK initialized and data collection enabled (`trackingConsent: .granted`); -- running app with SDK initialised but data collection disabled (`trackingConsent: .notGranted`). +- running app with SDK initialized but data collection disabled (`trackingConsent: .notGranted`). -**Application info:** +**Application info**: [Beam](https://github.com/awkward/beam) is an open source Reddit client, available on the App Store (see [Beam for reddit](https://apps.apple.com/us/app/beam-for-reddit/id937987469)). All tests were performed for [759623f](https://github.com/awkward/beam/commit/759623fae6df021d9a04ab5ef63cb6520029fba6) commit using `Release` configuration with debugger attached. -**iOS Device info:** +**iOS Device info**: -All data was collected on iPhone 13 (MLPF3F/A), running iOS 16.4.1 (a) with 42,54GB of available memory (out of 128GB). This device had 147 apps installed, but no other application was running during tests. Device was connected to LTE network and WIFI interface. +All data was collected on iPhone 13 (MLPF3F/A), running iOS 16.4.1 (a) with 42,54GB of available memory (out of 128GB). This device had 147 apps installed, but no other applications were running during the tests. The device was connected to the LTE network and WIFI interface. ## Results Rows represent individual test runs and columns represent each scenario: -- “SDK not initialised” - SDK is installed, but not initialised. +- “SDK not initialized” - SDK is installed, but not initialized. - “Consent granted” - SDK is initialized with data collection enabled (tracking consent: `.granted`). @@ -40,7 +40,7 @@ Rows represent individual test runs and columns represent each scenario: |#4|36%|42%|38%| |#5|44%|37%|36%| -no significant difference in CPU pick +No significant difference in CPU pick. ### Max RAM (in MBs) @@ -53,7 +53,7 @@ Rows represent individual test runs and columns represent each scenario: |#5|66.6MB|72.7 MB|77.3 MB| -no significant difference in RAM pick +No significant difference in RAM pick. ### High CPU Utilization (% of periods with CPU utilization higher than 20%) @@ -67,7 +67,7 @@ CPU usage of greater than 20%. High CPU utilization rapidly drains a device’s |#4|2.9%|3.1%|3.3%| |#5|3%|2.8%|3.4%| -no significant difference in high CPU utilization +No significant difference in high CPU utilization. ### Battery Utilization - Overhead @@ -81,7 +81,7 @@ Overhead represents energy use as a result of bringing up radios and other syste |#4|40.6%|43.5%|37.9%| |#5|40.1%|44%|36.7%| -We see a minor decrease of overhead when the SDK is not initialized or without consent but it is not significant enough to measure an impact +We see a minor decrease of overhead when the SDK is not initialized or without consent but it is not significant enough to measure an impact. ### Network Utilization (Uploads / Downloads) @@ -95,7 +95,7 @@ This table includes data sent and received by Datadog SDK only. |#4|n/a|U: 21.86 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| |#5|n/a|U: 22.02 KB / D: 1.68 KB|U: 0 KB / D: 0 KB| -no increase in energy use due to networking +No increase in energy use due to networking. ### Disk Usage (Reads / Writes) @@ -107,7 +107,7 @@ This table includes data sent and received by Datadog SDK only. |#4|R: 32.5 MB / W: 43.1 MB|R: 30.6 MB / W: 45.4 MB|R: 34.5 MB / W: 43.1 MB| |#5|R: 29.4 MB / W: 43.9 MB|R: 33.1 MB / W: 45.8 MB|R: 32.2 MB MB / W: 50.5 MB| -no significant impact on disk usage. +No significant impact on disk usage. ### Application Launch Time (cold start in seconds) @@ -121,7 +121,7 @@ The application launch time is the time interval between the application process |#4|0.607|0.413|0.536| |#5|1.132|0.331|0.920| -no significant difference in application launch time +No significant difference in application launch time. ### Bundle Size @@ -131,21 +131,21 @@ The application launch time is the time interval between the application process ## Appendix -**Test scenario:** +**Test scenario**: The test scenario was designed to simulate typical app usage (browsing reddits, user profiles and their comments). To eliminate external factors and noise (like another process being woken up or the app executing slightly different logic) the scenario was run multiple times: -- 5 times for each of 3 configurations to record performance data with Xcode Debug Navigator, +- 5 times for each of 3 configurations to record performance data with Xcode Debug Navigator. -- 5 times for “SDK initialised and data collection enabled” to record network traffic with Charles Proxy, +- 5 times for “SDK initialized and data collection enabled” to record network traffic with Charles Proxy. -- 1 time for “SDK not initialised” and “data collection disabled” scenarios to record network traffic. +- 1 time for “SDK not initialized” and “data collection disabled” scenarios to record network traffic. Each test run took exactly `2min 30s`: -1. Install the app on device. +1. Install the app on the device. -2. Launch the app, dismiss onboarding screen (_“Explore without account”_) and push notifications alert. +2. Launch the app, dismiss onboarding screen (_“Explore without account”_), and push notifications alert. 3. `20s`: Wait. @@ -153,21 +153,21 @@ Each test run took exactly `2min 30s`: 5. `20s`: Wait. -6. `30s`: Repeat step 4th with browsing “DYI” reddit and searching for “soccer”. +6. `30s`: Repeat step 4 with browsing “DYI” reddit and searching for “soccer”. 7. `50s`: Wait. -8. Pause the App process and record measurements. +8. Pause the app process and record measurements. -**Measurements:** +**Measurements**: -All performance results were collected using Xcode 14.3 Debug Navigator. The app process was paused `2min 30s` after launch and its state was dumped by taking screenshots of navigator sections: CPU, Memory, Energy, Disk, Network and GPU. +All performance results were collected using Xcode 14.3 Debug Navigator. The app process was paused `2min 30s` after launch and its state was dumped by taking screenshots of navigator sections: CPU, Memory, Energy, Disk, Network, and GPU. -Network traffic was recorded with Charles Proxy 4.6.4\. The proxy was enabled before each run and disabled after `2min 30s`. +Network traffic was recorded with Charles Proxy 4.6.4. The proxy was enabled before each run and disabled after `2min 30s`. **Instrumentation:** -The SDK was initialized with basic RUM instrumentation for tracking views, actions and resources: +The SDK was initialized with basic RUM instrumentation for tracking views, actions, and resources: ```swift Datadog.initialize( From 16783a43c55341c72169deb1692a050f3d7da8c8 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 18 Sep 2024 13:36:08 +0200 Subject: [PATCH 21/43] RUM-6224 Create override properties --- Datadog/Datadog.xcodeproj/project.pbxproj | 5 ++ .../SessionReplayConfiguration.swift | 9 +++ .../PrivacyLevel+SessionReplay.swift | 57 +++++++++++++++++++ .../Sources/SessionReplayConfiguration.swift | 4 +- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 30a8a1f3c1..40d65fb895 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -681,6 +681,7 @@ 61FDBA1726974CA9001D9D43 /* DDCrashReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1626974CA9001D9D43 /* DDCrashReportBuilderTests.swift */; }; 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */; }; 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; + 966253B62C98807400B90B63 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; 969B3B212C33F80500D62400 /* UIActivityIndicatorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */; }; 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */; }; @@ -2733,6 +2734,7 @@ 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 /* ViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifier.swift; sourceTree = ""; }; + 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrivacyLevel+SessionReplay.swift"; sourceTree = ""; }; 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorRecorder.swift; sourceTree = ""; }; 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorRecorderTests.swift; sourceTree = ""; }; 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorder.swift; sourceTree = ""; }; @@ -3630,6 +3632,8 @@ 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */, 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */, D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */, + 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, + 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */, 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */, 61054E172A6EE10A00AAA894 /* SystemColors.swift */, 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */, @@ -8401,6 +8405,7 @@ D2BCB2A12B7B8107005C2AAB /* WKWebViewRecorder.swift in Sources */, 61054E712A6EE10A00AAA894 /* TouchSnapshot.swift in Sources */, 61054E8A2A6EE10A00AAA894 /* WindowViewTreeSnapshotProducer.swift in Sources */, + 966253B62C98807400B90B63 /* PrivacyLevel+SessionReplay.swift in Sources */, 61054E7A2A6EE10A00AAA894 /* UIImageViewRecorder.swift in Sources */, A7B932FC2B1F6A0A00AE6477 /* SRDataModels.swift in Sources */, A795069C2B974C8200AC4814 /* SessionReplay+objc.swift in Sources */, diff --git a/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift b/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift index 5dc8ef3a56..0501693c88 100644 --- a/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift +++ b/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift @@ -57,6 +57,15 @@ public enum TouchPrivacyLevel: String { case hide } +/// Privacy level for overriding global privacy settings in Session Replay. +public enum HiddenPrivacyLevel: String { + /// Hides the view and replace it with an opaque gray wireframe, ignoring all child views and interactions. + case hide + + /// Removes the override, and apply the global or inherited privacy level instead. + case none +} + // MARK: SessionReplayConfiguration /// The Session Replay shared configuration. diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift new file mode 100644 index 0000000000..e92a68f368 --- /dev/null +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift @@ -0,0 +1,57 @@ +// +// PrivacyLevel+SessionReplay.swift +// DatadogSessionReplay iOS +// +// Created by Marie Denis on 16/09/2024. +// Copyright © 2024 Datadog. All rights reserved. +// + +#if os(iOS) +import Foundation +import UIKit +import DatadogInternal +import ObjectiveC + +private var associatedSessionReplayOverrideKey: UInt8 = 3 + +/// Extension for `UIView` to add the ability to override Session Replay privacy settings. +extension DatadogExtension where ExtendedType: UIView { + /// Provides access to privacy overrides for the current view. + /// This allows setting specific privacy levels for text & input, image, and touch masking, as well as hiding the view. + /// Usage: `myView.dd.sessionReplayOverride.textAndInputPrivacy = .maskNone` + public var sessionReplayOverride: SessionReplayOverride { + get { + if let override = objc_getAssociatedObject(self, &associatedSessionReplayOverrideKey) as? SessionReplayOverride { + return override + } else { + return SessionReplayOverride() + } + } + set { + objc_setAssociatedObject(self, &associatedSessionReplayOverrideKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +/// Structure encapsulating all privacy levels that can be overridden at the view level. +public struct SessionReplayOverride { + /// Privacy levels for masking within the view. + /// For each property, if `nil`, the global privacy configuration is applied. + public var textAndInputPrivacy: TextAndInputPrivacyLevel? + public var imagePrivacy: ImagePrivacyLevel? + public var touchPrivacy: TouchPrivacyLevel? + public var hiddenPrivacy: HiddenPrivacyLevel? + + public init( + textAndInputPrivacy: TextAndInputPrivacyLevel? = nil, + imagePrivacy: ImagePrivacyLevel? = nil, + touchPrivacy: TouchPrivacyLevel? = nil, + hiddenPrivacy: HiddenPrivacyLevel? = nil + ) { + self.textAndInputPrivacy = textAndInputPrivacy + self.imagePrivacy = imagePrivacy + self.touchPrivacy = touchPrivacy + self.hiddenPrivacy = hiddenPrivacy + } +} +#endif diff --git a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift index c66ad250b0..e821c867e4 100644 --- a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift +++ b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift @@ -77,9 +77,9 @@ extension SessionReplay { /// - Parameters: /// - replaySampleRate: The sampling rate for Session Replay. It is applied in addition to the RUM session sample rate. /// - textAndInputPrivacyLevel: The way texts and inputs (e.g. label, textfield, checkbox) should be masked. Default: `.maskAll`. + /// - imagePrivacyLevel: The way images should be masked. Default: `.maskAll`. /// - touchPrivacyLevel: The way user touches (e.g. tap) should be masked. Default: `.hide`. - /// - defaultImageRecordingLevel: Image recording privacy level. Default: `.maskAll`. - + /// - startRecordingImmediately: If the recording should start automatically. When `true`, the recording starts automatically; when `false` it doesn't, and the recording will need to be started manually. Default: `true`. /// - customEndpoint: Custom server url for sending replay data. Default: `nil`. public init( // swiftlint:disable:this function_default_parameter_at_end replaySampleRate: Float = 100, From 86b7c01428fd49204a25dfa58e4eb3f3e38b430e Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 19 Sep 2024 14:15:01 +0200 Subject: [PATCH 22/43] RUM-6224 Encapsulate overrides in SessionReplayOverride struct --- .../PrivacyLevel+SessionReplay.swift | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift index e92a68f368..50e62d580e 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift @@ -7,51 +7,78 @@ // #if os(iOS) -import Foundation import UIKit import DatadogInternal -import ObjectiveC -private var associatedSessionReplayOverrideKey: UInt8 = 3 +// MARK: - DatadogExtension for UIView -/// Extension for `UIView` to add the ability to override Session Replay privacy settings. +/// Extension on `DatadogExtension` to provide access to `SessionReplayOverride` for any `UIView`. extension DatadogExtension where ExtendedType: UIView { - /// Provides access to privacy overrides for the current view. - /// This allows setting specific privacy levels for text & input, image, and touch masking, as well as hiding the view. - /// Usage: `myView.dd.sessionReplayOverride.textAndInputPrivacy = .maskNone` - public var sessionReplayOverride: SessionReplayOverride { + /// Provides access to Session Replay override settings for the view. + /// Usage: `myView.dd.sessionReplayOverride.textAndInputPrivacy = .maskNone`. + public var sessionReplayOverride: SessionReplayOverrideExtension { get { - if let override = objc_getAssociatedObject(self, &associatedSessionReplayOverrideKey) as? SessionReplayOverride { - return override - } else { - return SessionReplayOverride() - } + return SessionReplayOverrideExtension(self.type) + } + set {} + } +} + +// MARK: - Associated Keys + +private var associatedTextAndInputPrivacyKey: UInt8 = 3 +private var associatedImagePrivacyKey: UInt8 = 4 +private var associatedTouchPrivacyKey: UInt8 = 5 +private var associatedHiddenPrivacyKey: UInt8 = 6 + +// MARK: - SessionReplayOverrideExtension + +/// `UIView` extension to manage the Session Replay privacy override settings. +public struct SessionReplayOverrideExtension { + private let view: ExtendedType + + public init(_ view: ExtendedType) { + self.view = view + } + + /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). + public var textAndInputPrivacy: TextAndInputPrivacyLevel? { + get { + return objc_getAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel } set { - objc_setAssociatedObject(self, &associatedSessionReplayOverrideKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Image privacy override (e.g., mask or unmask specific images). + public var imagePrivacy: ImagePrivacyLevel? { + get { + return objc_getAssociatedObject(view as AnyObject, &associatedImagePrivacyKey) as? ImagePrivacyLevel + } + set { + objc_setAssociatedObject(view as AnyObject, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Touch privacy override (e.g., hide or show touch interactions on specific views). + public var touchPrivacy: TouchPrivacyLevel? { + get { + return objc_getAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey) as? TouchPrivacyLevel + } + set { + objc_setAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } -} -/// Structure encapsulating all privacy levels that can be overridden at the view level. -public struct SessionReplayOverride { - /// Privacy levels for masking within the view. - /// For each property, if `nil`, the global privacy configuration is applied. - public var textAndInputPrivacy: TextAndInputPrivacyLevel? - public var imagePrivacy: ImagePrivacyLevel? - public var touchPrivacy: TouchPrivacyLevel? - public var hiddenPrivacy: HiddenPrivacyLevel? - - public init( - textAndInputPrivacy: TextAndInputPrivacyLevel? = nil, - imagePrivacy: ImagePrivacyLevel? = nil, - touchPrivacy: TouchPrivacyLevel? = nil, - hiddenPrivacy: HiddenPrivacyLevel? = nil - ) { - self.textAndInputPrivacy = textAndInputPrivacy - self.imagePrivacy = imagePrivacy - self.touchPrivacy = touchPrivacy - self.hiddenPrivacy = hiddenPrivacy + /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). + public var hiddenPrivacy: HiddenPrivacyLevel? { + get { + return objc_getAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey) as? HiddenPrivacyLevel + } + set { + objc_setAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } } } #endif From e52d9489d5911d67f595d01a4fa60535df94c1ca Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 19 Sep 2024 14:49:41 +0200 Subject: [PATCH 23/43] RUM-6224 Add unit tests --- Datadog/Datadog.xcodeproj/project.pbxproj | 4 ++ .../PrivacyLevel+SessionReplay.swift | 12 ++-- .../Tests/SessionReplayOverrideTests.swift | 62 +++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 40d65fb895..b3ea480e9e 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -690,6 +690,7 @@ 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 96F69D6E2CBE94F500A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; + 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -2739,6 +2740,7 @@ 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorRecorderTests.swift; sourceTree = ""; }; 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorder.swift; sourceTree = ""; }; 96E414152C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorderTests.swift; sourceTree = ""; }; + 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayOverrideTests.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 = ""; }; @@ -3578,6 +3580,7 @@ 61054E022A6EE0DB00AAA894 /* DatadogSessionReplayTests */ = { isa = PBXGroup; children = ( + 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */, 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */, 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */, D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, @@ -8477,6 +8480,7 @@ 61054FB92A6EE1BA00AAA894 /* UINavigationBarRecorderTests.swift in Sources */, 61054FA62A6EE1BA00AAA894 /* SnapshotProcessorTests.swift in Sources */, 61054FB72A6EE1BA00AAA894 /* UISegmentRecorderTests.swift in Sources */, + 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */, A7D9528A2B28BD94004C79B1 /* ResourceProcessorSpy.swift in Sources */, A7D9528C2B28C18D004C79B1 /* ResourceProcessorTests.swift in Sources */, A74A72892B10D95D00771FEB /* MultipartBuilderSpy.swift in Sources */, diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift index 50e62d580e..9140e432ca 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift @@ -1,10 +1,8 @@ -// -// PrivacyLevel+SessionReplay.swift -// DatadogSessionReplay iOS -// -// Created by Marie Denis on 16/09/2024. -// Copyright © 2024 Datadog. All rights reserved. -// +/* + * 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 UIKit diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift new file mode 100644 index 0000000000..ebad68ea03 --- /dev/null +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -0,0 +1,62 @@ +/* + * 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 XCTest +import UIKit +@testable import DatadogSessionReplay + +class SessionReplayOverrideTests: XCTestCase { + func testWhenNoOverrideIsSet_itDefaultsToNil() { + // Given + let view = UIView() + + // Then + XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.hiddenPrivacy) + } + + func testWithOverrides() { + // Given + var view = UIView() + + // When + view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayOverride.imagePrivacy = .maskAll + view.dd.sessionReplayOverride.touchPrivacy = .hide + view.dd.sessionReplayOverride.hiddenPrivacy = .hide + + // Then + XCTAssertEqual(view.dd.sessionReplayOverride.textAndInputPrivacy, .maskAllInputs) + XCTAssertEqual(view.dd.sessionReplayOverride.imagePrivacy, .maskAll) + XCTAssertEqual(view.dd.sessionReplayOverride.touchPrivacy, .hide) + XCTAssertEqual(view.dd.sessionReplayOverride.hiddenPrivacy, .hide) + } + + func testRemovingOverrides() { + // Given + var view = UIView() + view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayOverride.imagePrivacy = .maskAll + view.dd.sessionReplayOverride.touchPrivacy = .hide + view.dd.sessionReplayOverride.hiddenPrivacy = .hide + + // When + view.dd.sessionReplayOverride.textAndInputPrivacy = nil + view.dd.sessionReplayOverride.imagePrivacy = nil + view.dd.sessionReplayOverride.touchPrivacy = nil + view.dd.sessionReplayOverride.hiddenPrivacy = nil + + // Then + XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.hiddenPrivacy) + } +} +#endif From 3e62f8ba5b15b17c41d10fb1a29c49df2ca169d3 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 19 Sep 2024 18:33:40 +0200 Subject: [PATCH 24/43] Add Objc compatibility --- Datadog/Datadog.xcodeproj/project.pbxproj | 9 ++ .../DDSessionReplayOverridesTests.swift | 91 +++++++++++ .../Sources/SessionReplay+objc.swift | 3 +- .../Sources/SessionReplayOverrides+objc.swift | 148 ++++++++++++++++++ 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift create mode 100644 DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index b3ea480e9e..bd591593dd 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -691,6 +691,8 @@ 96F69D6E2CBE94F500A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; + 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */; }; + 96E863772C9C7ECF0023BF78 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -2741,6 +2743,8 @@ 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorder.swift; sourceTree = ""; }; 96E414152C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorderTests.swift; sourceTree = ""; }; 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayOverrideTests.swift; sourceTree = ""; }; + 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionReplayOverrides+objc.swift"; sourceTree = ""; }; + 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSessionReplayOverridesTests.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 = ""; }; @@ -3566,6 +3570,7 @@ 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */, 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, A795069B2B974C8100AC4814 /* SessionReplay+objc.swift */, + 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */, 61054E3B2A6EE10A00AAA894 /* Feature */, 61054E482A6EE10A00AAA894 /* Processor */, 61054E0D2A6EE10A00AAA894 /* Recorder */, @@ -4338,6 +4343,8 @@ A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */, 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */, 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, + D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, + 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); @@ -8189,6 +8196,7 @@ 6147989C2A459E2B0095CB02 /* DDTrace+apiTests.m in Sources */, D22743EB29DEC9E6001A7EF9 /* Casting+RUM.swift in Sources */, 61DA8CB2286215DE0074A606 /* CryptographyTests.swift in Sources */, + 96E863772C9C7ECF0023BF78 /* DDSessionReplayOverridesTests.swift in Sources */, 615A4A8924A34FD700233986 /* DDTracerTests.swift in Sources */, 6128F58A2BA9860B00D35B08 /* DataStoreFileReaderTests.swift in Sources */, 61A2CC212A443D330000FF25 /* DDRUMConfigurationTests.swift in Sources */, @@ -8369,6 +8377,7 @@ 61054E612A6EE10A00AAA894 /* SRCompression.swift in Sources */, 61054E8F2A6EE10A00AAA894 /* SegmentRequestBuilder.swift in Sources */, 61054E8B2A6EE10A00AAA894 /* SessionReplayFeature.swift in Sources */, + 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */, 61054E992A6EE10A00AAA894 /* WireframesBuilder.swift in Sources */, 61054E892A6EE10A00AAA894 /* NodeIDGenerator.swift in Sources */, 61054E962A6EE10A00AAA894 /* Diff+SRWireframes.swift in Sources */, diff --git a/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift b/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift new file mode 100644 index 0000000000..bdf9db7924 --- /dev/null +++ b/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift @@ -0,0 +1,91 @@ +/* + * 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 XCTest +import TestUtilities +import DatadogInternal + +@testable import DatadogSessionReplay + +class DDSessionReplayOverrideTests: XCTestCase { + func testTextAndInputPrivacyLevelsOverrideInterop() { + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAll._swift, .maskAll) + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAllInputs._swift, .maskAllInputs) + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskSensitiveInputs._swift, .maskSensitiveInputs) + XCTAssertNil(DDTextAndInputPrivacyLevelOverride.none._swift) + + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskAll), .maskAll) + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskAllInputs), .maskAllInputs) + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskSensitiveInputs), .maskSensitiveInputs) + XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(nil), .none) + } + + func testImagePrivacyLevelsOverrideInterop() { + XCTAssertEqual(DDImagePrivacyLevelOverride.maskAll._swift, .maskAll) + XCTAssertEqual(DDImagePrivacyLevelOverride.maskNonBundledOnly._swift, .maskNonBundledOnly) + XCTAssertEqual(DDImagePrivacyLevelOverride.maskNone._swift, .maskNone) + XCTAssertNil(DDImagePrivacyLevelOverride.none._swift) + + XCTAssertEqual(DDImagePrivacyLevelOverride(.maskAll), .maskAll) + XCTAssertEqual(DDImagePrivacyLevelOverride(.maskNonBundledOnly), .maskNonBundledOnly) + XCTAssertEqual(DDImagePrivacyLevelOverride(.maskNone), .maskNone) + XCTAssertEqual(DDImagePrivacyLevelOverride(nil), .none) + } + + func testTouchPrivacyLevelsOverrideInterop() { + XCTAssertEqual(DDTouchPrivacyLevelOverride.show._swift, .show) + XCTAssertEqual(DDTouchPrivacyLevelOverride.hide._swift, .hide) + XCTAssertNil(DDTouchPrivacyLevelOverride.none._swift) + + XCTAssertEqual(DDTouchPrivacyLevelOverride(.show), .show) + XCTAssertEqual(DDTouchPrivacyLevelOverride(.hide), .hide) + XCTAssertEqual(DDTouchPrivacyLevelOverride(nil), .none) + } + + func testHiddenPrivacyLevelsOverrideInterop() { + XCTAssertEqual(DDHiddenPrivacyLevelOverride.hide._swift, .hide) + XCTAssertNil(DDHiddenPrivacyLevelOverride.none._swift) + + XCTAssertEqual(DDHiddenPrivacyLevelOverride(.hide), .hide) + XCTAssertEqual(DDHiddenPrivacyLevelOverride(nil), .none) + } + + func testSettingAndRemovingPrivacyOverridesObjc() { + // Given + let override = DDSessionReplayOverride() + let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! + let hiddenPrivacy: DDHiddenPrivacyLevelOverride = [.hide, .none].randomElement()! + + // When + override.textAndInputPrivacy = textAndInputPrivacy + override.imagePrivacy = imagePrivacy + override.touchPrivacy = touchPrivacy + override.hiddenPrivacy = hiddenPrivacy + + // Then + XCTAssertEqual(override.textAndInputPrivacy, textAndInputPrivacy) + XCTAssertEqual(override.imagePrivacy, imagePrivacy) + XCTAssertEqual(override.touchPrivacy, touchPrivacy) + XCTAssertEqual(override.hiddenPrivacy, hiddenPrivacy) + + // When + override.textAndInputPrivacy = .none + override.imagePrivacy = .none + override.touchPrivacy = .none + override.hiddenPrivacy = .none + + // Then + XCTAssertEqual(override.textAndInputPrivacy, .none) + XCTAssertEqual(override.imagePrivacy, .none) + XCTAssertEqual(override.touchPrivacy, .none) + XCTAssertEqual(override.hiddenPrivacy, .none) + } +} +#endif diff --git a/DatadogSessionReplay/Sources/SessionReplay+objc.swift b/DatadogSessionReplay/Sources/SessionReplay+objc.swift index 2cc14336af..794e327b96 100644 --- a/DatadogSessionReplay/Sources/SessionReplay+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplay+objc.swift @@ -230,7 +230,7 @@ public enum objc_ImagePrivacyLevel: Int { } } -/// Available privacy levels for content masking. +/// Available privacy levels for touch masking. @objc(DDTouchPrivacyLevel) @_spi(objc) public enum objc_TouchPrivacyLevel: Int { @@ -254,5 +254,4 @@ public enum objc_TouchPrivacyLevel: Int { } } } - #endif diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift new file mode 100644 index 0000000000..1fdbe1b6fc --- /dev/null +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -0,0 +1,148 @@ +/* + * 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 UIKit + +/// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. +@objc +public final class DDSessionReplayOverride: NSObject { + /// Internal Swift equivalent of the Session Replay Override, tied to the view. + internal var _swift: SessionReplayOverrideExtension + + @objc + override public init() { + _swift = SessionReplayOverrideExtension(UIView()) + super.init() + } + + /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). + @objc public var textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride { + get { return .init(_swift.textAndInputPrivacy) } + set { _swift.textAndInputPrivacy = newValue._swift } + } + + /// Image privacy override (e.g., mask or unmask specific images). + @objc public var imagePrivacy: DDImagePrivacyLevelOverride { + get { return .init(_swift.imagePrivacy) } + set { _swift.imagePrivacy = newValue._swift } + } + + /// Touch privacy override (e.g., hide or show touch interactions on specific views). + @objc public var touchPrivacy: DDTouchPrivacyLevelOverride { + get { return .init(_swift.touchPrivacy) } + set { _swift.touchPrivacy = newValue._swift } + } + + /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). + @objc public var hiddenPrivacy: DDHiddenPrivacyLevelOverride { + get { return .init(_swift.hiddenPrivacy) } + set { _swift.hiddenPrivacy = newValue._swift } + } +} + +/// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). +@objc +public enum DDTextAndInputPrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case maskSensitiveInputs + case maskAllInputs + case maskAll + + internal var _swift: TextAndInputPrivacyLevel? { + switch self { + case .none: return nil + case .maskSensitiveInputs: return .maskSensitiveInputs + case .maskAllInputs: return .maskAllInputs + case .maskAll: return .maskAll + } + } + + internal init(_ swift: TextAndInputPrivacyLevel?) { + switch swift { + case .maskSensitiveInputs: self = .maskSensitiveInputs + case .maskAllInputs: self = .maskAllInputs + case .maskAll: self = .maskAll + case nil: self = .none + } + } +} + +/// Image privacy override (e.g., mask or unmask specific images). +@objc +public enum DDImagePrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case maskNone + case maskNonBundledOnly + case maskAll + + internal var _swift: ImagePrivacyLevel? { + switch self { + case .none: return nil + case .maskNone: return .maskNone + case .maskNonBundledOnly: return .maskNonBundledOnly + case .maskAll: return .maskAll + } + } + + internal init(_ swift: ImagePrivacyLevel?) { + switch swift { + case .maskNone: self = .maskNone + case .maskNonBundledOnly: self = .maskNonBundledOnly + case .maskAll: self = .maskAll + case nil: self = .none + } + } +} + +/// Touch privacy override (e.g., hide or show touch interactions on specific views). +@objc +public enum DDTouchPrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case show + case hide + + internal var _swift: TouchPrivacyLevel? { + switch self { + case .none: return nil + case .show: return .show + case .hide: return .hide + } + } + + internal init(_ swift: TouchPrivacyLevel?) { + switch swift { + case .show: self = .show + case .hide: self = .hide + case nil: self = .none + } + } +} + +/// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). +@objc +public enum DDHiddenPrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case hide + + internal var _swift: HiddenPrivacyLevel? { + switch self { + case .none: return nil + case .hide: return .hide + } + } + + internal init(_ swift: HiddenPrivacyLevel?) { + if let swift = swift { + self = (swift == .hide) ? .hide : .none + } else { + self = .none + } + } +} +#endif From d451157361e248737d59831cfce72103e7f3ff1c Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Mon, 23 Sep 2024 17:52:15 +0200 Subject: [PATCH 25/43] RUM-6224 Move extension to internal module --- Datadog/Datadog.xcodeproj/project.pbxproj | 6 +++++- .../Models/SessionReplay}/PrivacyLevel+SessionReplay.swift | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) rename {DatadogSessionReplay/Sources/Recorder/Utilities => DatadogInternal/Sources/Models/SessionReplay}/PrivacyLevel+SessionReplay.swift (99%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index bd591593dd..d2feba22a7 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -693,6 +693,8 @@ 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */; }; 96E863772C9C7ECF0023BF78 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; + 96E863802CA1B59C0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; + 96E863812CA1B59D0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -6473,6 +6475,7 @@ isa = PBXGroup; children = ( D2EA0F452C0E1AE200CB20F8 /* SessionReplayConfiguration.swift */, + 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, ); path = SessionReplay; sourceTree = ""; @@ -8417,7 +8420,6 @@ D2BCB2A12B7B8107005C2AAB /* WKWebViewRecorder.swift in Sources */, 61054E712A6EE10A00AAA894 /* TouchSnapshot.swift in Sources */, 61054E8A2A6EE10A00AAA894 /* WindowViewTreeSnapshotProducer.swift in Sources */, - 966253B62C98807400B90B63 /* PrivacyLevel+SessionReplay.swift in Sources */, 61054E7A2A6EE10A00AAA894 /* UIImageViewRecorder.swift in Sources */, A7B932FC2B1F6A0A00AE6477 /* SRDataModels.swift in Sources */, A795069C2B974C8200AC4814 /* SessionReplay+objc.swift in Sources */, @@ -8757,6 +8759,7 @@ D2160C9A29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, D2EBEE2229BA160F00B15732 /* TracePropagationHeadersReader.swift in Sources */, D2303A02298D5236001A1FA3 /* ReadWriteLock.swift in Sources */, + 96E863802CA1B59C0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */, D2EBEE2429BA160F00B15732 /* W3CHTTPHeadersReader.swift in Sources */, A7FA98CE2BA1A6930018D6B5 /* MethodCalledMetric.swift in Sources */, D23039E8298D5236001A1FA3 /* DatadogContext.swift in Sources */, @@ -9745,6 +9748,7 @@ D2160C9B29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, D2EBEE3029BA161100B15732 /* TracePropagationHeadersReader.swift in Sources */, D2DA2373298D57AA00C6C7E6 /* ReadWriteLock.swift in Sources */, + 96E863812CA1B59D0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */, D2EBEE3229BA161100B15732 /* W3CHTTPHeadersReader.swift in Sources */, A7FA98CF2BA1A6930018D6B5 /* MethodCalledMetric.swift in Sources */, D2DA2374298D57AA00C6C7E6 /* DatadogContext.swift in Sources */, diff --git a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift b/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift similarity index 99% rename from DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift rename to DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift index 9140e432ca..659d161b84 100644 --- a/DatadogSessionReplay/Sources/Recorder/Utilities/PrivacyLevel+SessionReplay.swift +++ b/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift @@ -6,7 +6,6 @@ #if os(iOS) import UIKit -import DatadogInternal // MARK: - DatadogExtension for UIView From 5c4afd0419cf73d74624bb6a314af9c0b962a1e3 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Tue, 24 Sep 2024 18:47:46 +0200 Subject: [PATCH 26/43] RUM-6224 Make hiddenPrivacy a Boolean and use OBJC_ASSOCIATION_COPY_NONATOMIC --- .../DDSessionReplayOverridesTests.swift | 30 ++++++++++---- .../PrivacyLevel+SessionReplay.swift | 12 +++--- .../SessionReplayConfiguration.swift | 9 ----- .../Sources/SessionReplayOverrides+objc.swift | 39 +++++++------------ .../Tests/SessionReplayOverrideTests.swift | 6 +-- 5 files changed, 46 insertions(+), 50 deletions(-) diff --git a/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift b/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift index bdf9db7924..970fb1e978 100644 --- a/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift +++ b/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift @@ -48,11 +48,27 @@ class DDSessionReplayOverrideTests: XCTestCase { } func testHiddenPrivacyLevelsOverrideInterop() { - XCTAssertEqual(DDHiddenPrivacyLevelOverride.hide._swift, .hide) - XCTAssertNil(DDHiddenPrivacyLevelOverride.none._swift) + let override = DDSessionReplayOverride() + + // When setting hiddenPrivacy via Swift + override._swift.hiddenPrivacy = true + XCTAssertEqual(override.hiddenPrivacy, NSNumber(value: true)) + + override._swift.hiddenPrivacy = false + XCTAssertEqual(override.hiddenPrivacy, NSNumber(value: false)) + + override._swift.hiddenPrivacy = nil + XCTAssertNil(override.hiddenPrivacy) + + // When setting hiddenPrivacy via Objective-C + override.hiddenPrivacy = NSNumber(value: true) + XCTAssertEqual(override._swift.hiddenPrivacy, true) + + override.hiddenPrivacy = NSNumber(value: false) + XCTAssertEqual(override._swift.hiddenPrivacy, false) - XCTAssertEqual(DDHiddenPrivacyLevelOverride(.hide), .hide) - XCTAssertEqual(DDHiddenPrivacyLevelOverride(nil), .none) + override.hiddenPrivacy = nil + XCTAssertNil(override._swift.hiddenPrivacy) } func testSettingAndRemovingPrivacyOverridesObjc() { @@ -61,7 +77,7 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! - let hiddenPrivacy: DDHiddenPrivacyLevelOverride = [.hide, .none].randomElement()! + let hiddenPrivacy: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil // When override.textAndInputPrivacy = textAndInputPrivacy @@ -79,13 +95,13 @@ class DDSessionReplayOverrideTests: XCTestCase { override.textAndInputPrivacy = .none override.imagePrivacy = .none override.touchPrivacy = .none - override.hiddenPrivacy = .none + override.hiddenPrivacy = false // Then XCTAssertEqual(override.textAndInputPrivacy, .none) XCTAssertEqual(override.imagePrivacy, .none) XCTAssertEqual(override.touchPrivacy, .none) - XCTAssertEqual(override.hiddenPrivacy, .none) + XCTAssertEqual(override.hiddenPrivacy, false) } } #endif diff --git a/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift b/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift index 659d161b84..f3d8d2341f 100644 --- a/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift +++ b/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift @@ -44,7 +44,7 @@ public struct SessionReplayOverrideExtension { return objc_getAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } @@ -54,7 +54,7 @@ public struct SessionReplayOverrideExtension { return objc_getAssociatedObject(view as AnyObject, &associatedImagePrivacyKey) as? ImagePrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view as AnyObject, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } @@ -64,17 +64,17 @@ public struct SessionReplayOverrideExtension { return objc_getAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey) as? TouchPrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - public var hiddenPrivacy: HiddenPrivacyLevel? { + public var hiddenPrivacy: Bool? { get { - return objc_getAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey) as? HiddenPrivacyLevel + return objc_getAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey) as? Bool } set { - objc_setAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } } diff --git a/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift b/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift index 0501693c88..5dc8ef3a56 100644 --- a/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift +++ b/DatadogInternal/Sources/Models/SessionReplay/SessionReplayConfiguration.swift @@ -57,15 +57,6 @@ public enum TouchPrivacyLevel: String { case hide } -/// Privacy level for overriding global privacy settings in Session Replay. -public enum HiddenPrivacyLevel: String { - /// Hides the view and replace it with an opaque gray wireframe, ignoring all child views and interactions. - case hide - - /// Removes the override, and apply the global or inherited privacy level instead. - case none -} - // MARK: SessionReplayConfiguration /// The Session Replay shared configuration. diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index 1fdbe1b6fc..79f52f91c0 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -40,9 +40,20 @@ public final class DDSessionReplayOverride: NSObject { } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - @objc public var hiddenPrivacy: DDHiddenPrivacyLevelOverride { - get { return .init(_swift.hiddenPrivacy) } - set { _swift.hiddenPrivacy = newValue._swift } + @objc public var hiddenPrivacy: NSNumber? { + get { + guard let hiddenPrivacy = _swift.hiddenPrivacy else { + return nil + } + return NSNumber(value: hiddenPrivacy) + } + set { + if let newValue = newValue { + _swift.hiddenPrivacy = newValue.boolValue + } else { + _swift.hiddenPrivacy = nil + } + } } } @@ -123,26 +134,4 @@ public enum DDTouchPrivacyLevelOverride: Int { } } } - -/// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). -@objc -public enum DDHiddenPrivacyLevelOverride: Int { - case none // Represents `nil` in Swift - case hide - - internal var _swift: HiddenPrivacyLevel? { - switch self { - case .none: return nil - case .hide: return .hide - } - } - - internal init(_ swift: HiddenPrivacyLevel?) { - if let swift = swift { - self = (swift == .hide) ? .hide : .none - } else { - self = .none - } - } -} #endif diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index ebad68ea03..b12f88fe00 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -29,13 +29,13 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hiddenPrivacy = .hide + view.dd.sessionReplayOverride.hiddenPrivacy = true // Then XCTAssertEqual(view.dd.sessionReplayOverride.textAndInputPrivacy, .maskAllInputs) XCTAssertEqual(view.dd.sessionReplayOverride.imagePrivacy, .maskAll) XCTAssertEqual(view.dd.sessionReplayOverride.touchPrivacy, .hide) - XCTAssertEqual(view.dd.sessionReplayOverride.hiddenPrivacy, .hide) + XCTAssertEqual(view.dd.sessionReplayOverride.hiddenPrivacy, true) } func testRemovingOverrides() { @@ -44,7 +44,7 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hiddenPrivacy = .hide + view.dd.sessionReplayOverride.hiddenPrivacy = true // When view.dd.sessionReplayOverride.textAndInputPrivacy = nil From ad11854f047fd120b0071a889a39163b5852d80a Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 25 Sep 2024 14:03:48 +0200 Subject: [PATCH 27/43] RUM-6224 Address CR comments --- Datadog/Datadog.xcodeproj/project.pbxproj | 15 ++++++--------- .../Sources}/PrivacyLevel+SessionReplay.swift | 14 ++++++-------- .../Sources/SessionReplayOverrides+objc.swift | 2 +- .../Tests}/DDSessionReplayOverridesTests.swift | 0 .../Tests/SessionReplayOverrideTests.swift | 4 ++-- 5 files changed, 15 insertions(+), 20 deletions(-) rename {DatadogInternal/Sources/Models/SessionReplay => DatadogSessionReplay/Sources}/PrivacyLevel+SessionReplay.swift (91%) rename {DatadogCore/Tests/DatadogObjc => DatadogSessionReplay/Tests}/DDSessionReplayOverridesTests.swift (100%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index d2feba22a7..6968b6aba0 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -681,7 +681,8 @@ 61FDBA1726974CA9001D9D43 /* DDCrashReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1626974CA9001D9D43 /* DDCrashReportBuilderTests.swift */; }; 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */; }; 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; - 966253B62C98807400B90B63 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; + 962C41A72CA431370050B747 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; + 962C41A82CA431AA0050B747 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; 969B3B212C33F80500D62400 /* UIActivityIndicatorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */; }; 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */; }; @@ -692,9 +693,6 @@ 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */; }; - 96E863772C9C7ECF0023BF78 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; - 96E863802CA1B59C0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; - 96E863812CA1B59D0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -3571,6 +3569,7 @@ children = ( 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */, 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, + 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, A795069B2B974C8100AC4814 /* SessionReplay+objc.swift */, 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */, 61054E3B2A6EE10A00AAA894 /* Feature */, @@ -3591,6 +3590,7 @@ 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */, 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */, D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, + 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */, 61054F882A6EE1BA00AAA894 /* Feature */, 61054F922A6EE1BA00AAA894 /* Helpers */, 61054F7D2A6EE1BA00AAA894 /* Mocks */, @@ -4346,7 +4346,6 @@ 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */, 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, - 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); @@ -6475,7 +6474,6 @@ isa = PBXGroup; children = ( D2EA0F452C0E1AE200CB20F8 /* SessionReplayConfiguration.swift */, - 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, ); path = SessionReplay; sourceTree = ""; @@ -8199,7 +8197,6 @@ 6147989C2A459E2B0095CB02 /* DDTrace+apiTests.m in Sources */, D22743EB29DEC9E6001A7EF9 /* Casting+RUM.swift in Sources */, 61DA8CB2286215DE0074A606 /* CryptographyTests.swift in Sources */, - 96E863772C9C7ECF0023BF78 /* DDSessionReplayOverridesTests.swift in Sources */, 615A4A8924A34FD700233986 /* DDTracerTests.swift in Sources */, 6128F58A2BA9860B00D35B08 /* DataStoreFileReaderTests.swift in Sources */, 61A2CC212A443D330000FF25 /* DDRUMConfigurationTests.swift in Sources */, @@ -8393,6 +8390,7 @@ 61054EA12A6EE10B00AAA894 /* MainThreadScheduler.swift in Sources */, 61054E7C2A6EE10A00AAA894 /* UINavigationBarRecorder.swift in Sources */, 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */, + 962C41A72CA431370050B747 /* PrivacyLevel+SessionReplay.swift in Sources */, 61054E772A6EE10A00AAA894 /* ViewTreeRecorder.swift in Sources */, 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */, 61054E872A6EE10A00AAA894 /* ViewAttributes+Copy.swift in Sources */, @@ -8509,6 +8507,7 @@ 61054FAA2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift in Sources */, 61054FA52A6EE1BA00AAA894 /* RecordsBuilderTests.swift in Sources */, 61054FD02A6EE1BA00AAA894 /* SRContextPublisherTests.swift in Sources */, + 962C41A82CA431AA0050B747 /* DDSessionReplayOverridesTests.swift in Sources */, 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, D2056C212BBFE05A0085BC76 /* WireframesBuilderTests.swift in Sources */, 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */, @@ -8759,7 +8758,6 @@ D2160C9A29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, D2EBEE2229BA160F00B15732 /* TracePropagationHeadersReader.swift in Sources */, D2303A02298D5236001A1FA3 /* ReadWriteLock.swift in Sources */, - 96E863802CA1B59C0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */, D2EBEE2429BA160F00B15732 /* W3CHTTPHeadersReader.swift in Sources */, A7FA98CE2BA1A6930018D6B5 /* MethodCalledMetric.swift in Sources */, D23039E8298D5236001A1FA3 /* DatadogContext.swift in Sources */, @@ -9748,7 +9746,6 @@ D2160C9B29C0DE5700FAA9A5 /* FirstPartyHosts.swift in Sources */, D2EBEE3029BA161100B15732 /* TracePropagationHeadersReader.swift in Sources */, D2DA2373298D57AA00C6C7E6 /* ReadWriteLock.swift in Sources */, - 96E863812CA1B59D0023BF78 /* PrivacyLevel+SessionReplay.swift in Sources */, D2EBEE3229BA161100B15732 /* W3CHTTPHeadersReader.swift in Sources */, A7FA98CF2BA1A6930018D6B5 /* MethodCalledMetric.swift in Sources */, D2DA2374298D57AA00C6C7E6 /* DatadogContext.swift in Sources */, diff --git a/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift similarity index 91% rename from DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift rename to DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift index f3d8d2341f..0a4e4f9962 100644 --- a/DatadogInternal/Sources/Models/SessionReplay/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift @@ -6,6 +6,7 @@ #if os(iOS) import UIKit +import DatadogInternal // MARK: - DatadogExtension for UIView @@ -13,11 +14,8 @@ import UIKit extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. /// Usage: `myView.dd.sessionReplayOverride.textAndInputPrivacy = .maskNone`. - public var sessionReplayOverride: SessionReplayOverrideExtension { - get { - return SessionReplayOverrideExtension(self.type) - } - set {} + public var sessionReplayOverride: SessionReplayOverrideExtension { + return SessionReplayOverrideExtension(self.type) } } @@ -31,10 +29,10 @@ private var associatedHiddenPrivacyKey: UInt8 = 6 // MARK: - SessionReplayOverrideExtension /// `UIView` extension to manage the Session Replay privacy override settings. -public struct SessionReplayOverrideExtension { - private let view: ExtendedType +public final class SessionReplayOverrideExtension { + private let view: UIView - public init(_ view: ExtendedType) { + public init(_ view: UIView) { self.view = view } diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index 79f52f91c0..77334aa7bb 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -13,7 +13,7 @@ import UIKit @objc public final class DDSessionReplayOverride: NSObject { /// Internal Swift equivalent of the Session Replay Override, tied to the view. - internal var _swift: SessionReplayOverrideExtension + internal var _swift: SessionReplayOverrideExtension @objc override public init() { diff --git a/DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift similarity index 100% rename from DatadogCore/Tests/DatadogObjc/DDSessionReplayOverridesTests.swift rename to DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index b12f88fe00..9cccb46b13 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -23,7 +23,7 @@ class SessionReplayOverrideTests: XCTestCase { func testWithOverrides() { // Given - var view = UIView() + let view = UIView() // When view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs @@ -40,7 +40,7 @@ class SessionReplayOverrideTests: XCTestCase { func testRemovingOverrides() { // Given - var view = UIView() + let view = UIView() view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide From 85dd0356cc878f4444134967d5af1bb21a37d542 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Mon, 23 Sep 2024 18:06:54 +0200 Subject: [PATCH 28/43] RUM-6223 Implement hidden override logic --- .../Sources/PrivacyLevel+SessionReplay.swift | 28 ++++++++++----- .../NodeRecorders/UIViewRecorder.swift | 14 ++++++++ .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 4 +++ .../Sources/SessionReplayOverrides+objc.swift | 10 +++--- .../Tests/DDSessionReplayOverridesTests.swift | 34 +++++++++--------- .../Tests/Mocks/RecorderMocks.swift | 35 +++++++++++++++++-- .../NodeRecorders/UIViewRecorderTests.swift | 14 ++++++++ .../ViewTreeSnapshotTests.swift | 34 ++++++++++++++++++ .../Tests/SessionReplayOverrideTests.swift | 12 +++---- 9 files changed, 146 insertions(+), 39 deletions(-) diff --git a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift index 0a4e4f9962..477618eee7 100644 --- a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift @@ -39,41 +39,51 @@ public final class SessionReplayOverrideExtension { /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). public var textAndInputPrivacy: TextAndInputPrivacyLevel? { get { - return objc_getAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel + return objc_getAssociatedObject(view, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(view, &associatedTextAndInputPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } /// Image privacy override (e.g., mask or unmask specific images). public var imagePrivacy: ImagePrivacyLevel? { get { - return objc_getAssociatedObject(view as AnyObject, &associatedImagePrivacyKey) as? ImagePrivacyLevel + return objc_getAssociatedObject(view, &associatedImagePrivacyKey) as? ImagePrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(view, &associatedImagePrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } /// Touch privacy override (e.g., hide or show touch interactions on specific views). public var touchPrivacy: TouchPrivacyLevel? { get { - return objc_getAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey) as? TouchPrivacyLevel + return objc_getAssociatedObject(view, &associatedTouchPrivacyKey) as? TouchPrivacyLevel } set { - objc_setAssociatedObject(view as AnyObject, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(view, &associatedTouchPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - public var hiddenPrivacy: Bool? { + public var hidden: Bool? { get { - return objc_getAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey) as? Bool + return objc_getAssociatedObject(view, &associatedHiddenPrivacyKey) as? Bool } set { - objc_setAssociatedObject(view as AnyObject, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(view, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } } + +extension SessionReplayOverrideExtension: Equatable { + public static func == (lhs: SessionReplayOverrideExtension, rhs: SessionReplayOverrideExtension) -> Bool { + return lhs.view === rhs.view + && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy + && lhs.imagePrivacy == rhs.imagePrivacy + && lhs.touchPrivacy == rhs.touchPrivacy + && lhs.hidden == rhs.hidden + } +} #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift index f8aa3eec25..aa830b6480 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift @@ -41,6 +41,15 @@ internal class UIViewRecorder: NodeRecorder { return semantics } + if attributes.sessionReplayOverride?.hidden == true { + let builder = UIViewWireframesBuilder( + wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), + attributes: attributes + ) + let node = Node(viewAttributes: attributes, wireframesBuilder: builder) + return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) + } + guard attributes.hasAnyAppearance else { // The view has no appearance, but it may contain subviews that bring visual elements, so // we use `InvisibleElement` semantics (to drop it) with `.record` strategy for its subview. @@ -66,6 +75,11 @@ internal struct UIViewWireframesBuilder: NodeWireframesBuilder { } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + if attributes.sessionReplayOverride?.hidden == true { + return [ + builder.createPlaceholderWireframe(id: wireframeID, frame: wireframeRect, label: "Hidden") + ] + } return [ builder.createShapeWireframe(id: wireframeID, frame: wireframeRect, attributes: attributes) ] diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 938b765d84..2f076fc447 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -123,6 +123,9 @@ public struct SessionReplayViewAttributes: Equatable { /// Example 1: A view with blue background of alpha `0.5` is considered "translucent". /// Example 2: A view with blue semi-transparent background, but alpha `1` is also conisdered "translucent". var isTranslucent: Bool { !isVisible || alpha < 1 || backgroundColor?.alpha ?? 0 < 1 } + + /// If the view has privacy overrides, which take precedence over global masking privacy levels. + var sessionReplayOverride: SessionReplayOverrideExtension? } // This alias enables us to have a more unique name exposed through public-internal access level @@ -138,6 +141,7 @@ extension ViewAttributes { self.alpha = view.alpha self.isHidden = view.isHidden self.intrinsicContentSize = view.intrinsicContentSize + self.sessionReplayOverride = view.dd.sessionReplayOverride } } diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index 77334aa7bb..ac6ee351bd 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -40,18 +40,18 @@ public final class DDSessionReplayOverride: NSObject { } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - @objc public var hiddenPrivacy: NSNumber? { + @objc public var hidden: NSNumber? { get { - guard let hiddenPrivacy = _swift.hiddenPrivacy else { + guard let hidden = _swift.hidden else { return nil } - return NSNumber(value: hiddenPrivacy) + return NSNumber(value: hidden) } set { if let newValue = newValue { - _swift.hiddenPrivacy = newValue.boolValue + _swift.hidden = newValue.boolValue } else { - _swift.hiddenPrivacy = nil + _swift.hidden = nil } } } diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index 970fb1e978..b639b0844c 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -51,24 +51,24 @@ class DDSessionReplayOverrideTests: XCTestCase { let override = DDSessionReplayOverride() // When setting hiddenPrivacy via Swift - override._swift.hiddenPrivacy = true - XCTAssertEqual(override.hiddenPrivacy, NSNumber(value: true)) + override._swift.hidden = true + XCTAssertEqual(override.hidden, NSNumber(value: true)) - override._swift.hiddenPrivacy = false - XCTAssertEqual(override.hiddenPrivacy, NSNumber(value: false)) + override._swift.hidden = false + XCTAssertEqual(override.hidden, NSNumber(value: false)) - override._swift.hiddenPrivacy = nil - XCTAssertNil(override.hiddenPrivacy) + override._swift.hidden = nil + XCTAssertNil(override.hidden) // When setting hiddenPrivacy via Objective-C - override.hiddenPrivacy = NSNumber(value: true) - XCTAssertEqual(override._swift.hiddenPrivacy, true) + override.hidden = NSNumber(value: true) + XCTAssertEqual(override._swift.hidden, true) - override.hiddenPrivacy = NSNumber(value: false) - XCTAssertEqual(override._swift.hiddenPrivacy, false) + override.hidden = NSNumber(value: false) + XCTAssertEqual(override._swift.hidden, false) - override.hiddenPrivacy = nil - XCTAssertNil(override._swift.hiddenPrivacy) + override.hidden = nil + XCTAssertNil(override._swift.hidden) } func testSettingAndRemovingPrivacyOverridesObjc() { @@ -77,31 +77,31 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! - let hiddenPrivacy: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil + let hidden: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil // When override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy - override.hiddenPrivacy = hiddenPrivacy + override.hidden = hidden // Then XCTAssertEqual(override.textAndInputPrivacy, textAndInputPrivacy) XCTAssertEqual(override.imagePrivacy, imagePrivacy) XCTAssertEqual(override.touchPrivacy, touchPrivacy) - XCTAssertEqual(override.hiddenPrivacy, hiddenPrivacy) + XCTAssertEqual(override.hidden, hidden) // When override.textAndInputPrivacy = .none override.imagePrivacy = .none override.touchPrivacy = .none - override.hiddenPrivacy = false + override.hidden = false // Then XCTAssertEqual(override.textAndInputPrivacy, .none) XCTAssertEqual(override.imagePrivacy, .none) XCTAssertEqual(override.touchPrivacy, .none) - XCTAssertEqual(override.hiddenPrivacy, false) + XCTAssertEqual(override.hidden, false) } } #endif diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 3260fb7c8f..de8cb21200 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -77,7 +77,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: CGFloat = .mockAny(), alpha: CGFloat = .mockAny(), isHidden: Bool = .mockAny(), - intrinsicContentSize: CGSize = .mockAny() + intrinsicContentSize: CGSize = .mockAny(), + sessionReplayOverride: SessionReplayOverrideExtension? = .mockAny() ) -> ViewAttributes { return .init( frame: frame, @@ -87,7 +88,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: layerCornerRadius, alpha: alpha, isHidden: isHidden, - intrinsicContentSize: intrinsicContentSize + intrinsicContentSize: intrinsicContentSize, + sessionReplayOverride: sessionReplayOverride ) } @@ -577,4 +579,33 @@ internal extension Optional where Wrapped == NodeSemantics { return try XCTUnwrap(builders.first, file: file, line: line) } } + +extension SessionReplayOverrideExtension: AnyMockable, RandomMockable { + public static func mockAny() -> SessionReplayOverrideExtension { + return mockWith() + } + + public static func mockRandom() -> SessionReplayOverrideExtension { + return mockWith( + textAndInputPrivacy: .mockRandom(), + imagePrivacy: .mockRandom(), + touchPrivacy: .mockRandom(), + hidden: .mockRandom() + ) + } + + public static func mockWith( + textAndInputPrivacy: TextAndInputPrivacyLevel? = nil, + imagePrivacy: ImagePrivacyLevel? = nil, + touchPrivacy: TouchPrivacyLevel? = nil, + hidden: Bool? = nil + ) -> SessionReplayOverrideExtension { + let override = SessionReplayOverrideExtension(UIView.mockRandom()) + override.textAndInputPrivacy = textAndInputPrivacy + override.imagePrivacy = imagePrivacy + override.touchPrivacy = touchPrivacy + override.hidden = hidden + return override + } +} #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 0ffa2e0ccf..2357c031b4 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -57,5 +57,19 @@ class UIViewRecorderTests: XCTestCase { XCTAssertTrue(semantics is AmbiguousElement) XCTAssertEqual(semantics.subtreeStrategy, .record) } + + func testWhenViewHasHiddenOverride() throws { + // Given + viewAttributes = .mock(fixture: .visible(.someAppearance)) + viewAttributes.sessionReplayOverride = .mockWith(hidden: true) + + // When + let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) + + // Then + XCTAssertTrue(semantics is SpecificElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore) + XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIViewWireframesBuilder) + } } #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index 4eb7fe862e..566cd88f3e 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -175,6 +175,40 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.isHidden, boolean) XCTAssertEqual(attributes.intrinsicContentSize, rect.size) } + + // MARK: Overrides + func testItDefaultsToNilWhenNoOverrideIsSet() { + // Given + let view: UIView = .mockRandom() + + // When + let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + + // Then + XCTAssertNil(attributes.sessionReplayOverride?.textAndInputPrivacy) + XCTAssertNil(attributes.sessionReplayOverride?.imagePrivacy) + XCTAssertNil(attributes.sessionReplayOverride?.touchPrivacy) + XCTAssertNil(attributes.sessionReplayOverride?.hidden) + } + + func testItCapturesViewAttributesWithOverrides() { + // Given + let view: UIView = .mockRandom() + let overrides = SessionReplayOverrideExtension.mockRandom() + view.dd.sessionReplayOverride.textAndInputPrivacy = overrides.textAndInputPrivacy + view.dd.sessionReplayOverride.imagePrivacy = overrides.imagePrivacy + view.dd.sessionReplayOverride.touchPrivacy = overrides.touchPrivacy + view.dd.sessionReplayOverride.hidden = overrides.hidden + + // When + let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + + // Then + XCTAssertEqual(attributes.sessionReplayOverride?.textAndInputPrivacy, overrides.textAndInputPrivacy) + XCTAssertEqual(attributes.sessionReplayOverride?.imagePrivacy, overrides.imagePrivacy) + XCTAssertEqual(attributes.sessionReplayOverride?.touchPrivacy, overrides.touchPrivacy) + XCTAssertEqual(attributes.sessionReplayOverride?.hidden, overrides.hidden) + } } // swiftlint:enable opening_brace diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index 9cccb46b13..5425994cd5 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -18,7 +18,7 @@ class SessionReplayOverrideTests: XCTestCase { XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hiddenPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.hidden) } func testWithOverrides() { @@ -29,13 +29,13 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hiddenPrivacy = true + view.dd.sessionReplayOverride.hidden = true // Then XCTAssertEqual(view.dd.sessionReplayOverride.textAndInputPrivacy, .maskAllInputs) XCTAssertEqual(view.dd.sessionReplayOverride.imagePrivacy, .maskAll) XCTAssertEqual(view.dd.sessionReplayOverride.touchPrivacy, .hide) - XCTAssertEqual(view.dd.sessionReplayOverride.hiddenPrivacy, true) + XCTAssertEqual(view.dd.sessionReplayOverride.hidden, true) } func testRemovingOverrides() { @@ -44,19 +44,19 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hiddenPrivacy = true + view.dd.sessionReplayOverride.hidden = true // When view.dd.sessionReplayOverride.textAndInputPrivacy = nil view.dd.sessionReplayOverride.imagePrivacy = nil view.dd.sessionReplayOverride.touchPrivacy = nil - view.dd.sessionReplayOverride.hiddenPrivacy = nil + view.dd.sessionReplayOverride.hidden = nil // Then XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hiddenPrivacy) + XCTAssertNil(view.dd.sessionReplayOverride.hidden) } } #endif From eef4cd46834d18ce5f435cd35680a7b7a27358d1 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 26 Sep 2024 17:34:22 +0200 Subject: [PATCH 29/43] RUM-6223 Expose extension to ObjC --- .../ObjcAPITests/DDSessionReplay+apiTests.m | 46 +++++++++++ .../Sources/PrivacyLevel+SessionReplay.swift | 4 +- .../NodeRecorders/UIViewRecorder.swift | 4 +- .../Sources/SessionReplayOverrides+objc.swift | 31 +++++-- .../Tests/DDSessionReplayOverridesTests.swift | 80 +++++++++++++++---- .../Tests/Mocks/RecorderMocks.swift | 6 +- .../NodeRecorders/UIViewRecorderTests.swift | 2 +- .../ViewTreeSnapshotTests.swift | 6 +- .../Tests/SessionReplayOverrideTests.swift | 12 +-- 9 files changed, 152 insertions(+), 39 deletions(-) diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index f6e62802ed..12b4d57e13 100644 --- a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m @@ -13,6 +13,7 @@ @interface DDSessionReplay_apiTests : XCTestCase @implementation DDSessionReplay_apiTests +// MARK: Configuration - (void)testConfiguration { DDSessionReplayConfiguration *configuration = [[DDSessionReplayConfiguration alloc] initWithReplaySampleRate:100]; configuration.defaultPrivacyLevel = DDSessionReplayConfigurationPrivacyLevelAllow; @@ -35,4 +36,49 @@ - (void)testStartAndStopRecording { [DDSessionReplay startRecording]; [DDSessionReplay stopRecording]; } + +// MARK: Overrides +- (void)testSettingAndGettingOverrides { + // Given + UIView *view = [[UIView alloc] init]; + DDSessionReplayOverride *override = [[DDSessionReplayOverride alloc] init]; + + // When + view.ddSessionReplayOverride = override; + view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayOverride.hide = @YES; + + // Then + XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, DDImagePrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, DDTouchPrivacyLevelOverrideHide); + XCTAssertTrue(view.ddSessionReplayOverride.hide.boolValue); +} + +- (void)testClearingOverride { + // Given + UIView *view = [[UIView alloc] init]; + DDSessionReplayOverride *override = [[DDSessionReplayOverride alloc] init]; + + // Set initial values + view.ddSessionReplayOverride = override; + view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayOverride.hide = @YES; + + // When + view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; + view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideNone; + view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideNone; + view.ddSessionReplayOverride.hide = nil; + + // Then + XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, DDImagePrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, DDTouchPrivacyLevelOverrideNone); + XCTAssertNil(view.ddSessionReplayOverride.hide); +} @end diff --git a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift index 477618eee7..7f234be6ef 100644 --- a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift @@ -67,7 +67,7 @@ public final class SessionReplayOverrideExtension { } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - public var hidden: Bool? { + public var hide: Bool? { get { return objc_getAssociatedObject(view, &associatedHiddenPrivacyKey) as? Bool } @@ -83,7 +83,7 @@ extension SessionReplayOverrideExtension: Equatable { && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy && lhs.imagePrivacy == rhs.imagePrivacy && lhs.touchPrivacy == rhs.touchPrivacy - && lhs.hidden == rhs.hidden + && lhs.hide == rhs.hide } } #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift index aa830b6480..76966fb994 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift @@ -41,7 +41,7 @@ internal class UIViewRecorder: NodeRecorder { return semantics } - if attributes.sessionReplayOverride?.hidden == true { + if attributes.sessionReplayOverride?.hide == true { let builder = UIViewWireframesBuilder( wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), attributes: attributes @@ -75,7 +75,7 @@ internal struct UIViewWireframesBuilder: NodeWireframesBuilder { } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { - if attributes.sessionReplayOverride?.hidden == true { + if attributes.sessionReplayOverride?.hide == true { return [ builder.createPlaceholderWireframe(id: wireframeID, frame: wireframeRect, label: "Hidden") ] diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index ac6ee351bd..db6b32ec18 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -9,6 +9,27 @@ import Foundation import DatadogInternal import UIKit +private var associatedSROverrideKey: UInt8 = 0 + +/// Objective-C accessible extension for UIView +@objc +public extension UIView { + @objc var ddSessionReplayOverride: DDSessionReplayOverride { + get { + if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayOverride { + return override + } else { + let override = DDSessionReplayOverride() + objc_setAssociatedObject(self, &associatedSROverrideKey, override, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return override + } + } + set { + objc_setAssociatedObject(self, &associatedSROverrideKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + /// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. @objc public final class DDSessionReplayOverride: NSObject { @@ -40,18 +61,18 @@ public final class DDSessionReplayOverride: NSObject { } /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - @objc public var hidden: NSNumber? { + @objc public var hide: NSNumber? { get { - guard let hidden = _swift.hidden else { + guard let hide = _swift.hide else { return nil } - return NSNumber(value: hidden) + return NSNumber(value: hide) } set { if let newValue = newValue { - _swift.hidden = newValue.boolValue + _swift.hide = newValue.boolValue } else { - _swift.hidden = nil + _swift.hide = nil } } } diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index b639b0844c..f3fcbfc653 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -13,6 +13,7 @@ import DatadogInternal @testable import DatadogSessionReplay class DDSessionReplayOverrideTests: XCTestCase { + // MARK: Overrides Interoperability func testTextAndInputPrivacyLevelsOverrideInterop() { XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAll._swift, .maskAll) XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAllInputs._swift, .maskAllInputs) @@ -51,57 +52,102 @@ class DDSessionReplayOverrideTests: XCTestCase { let override = DDSessionReplayOverride() // When setting hiddenPrivacy via Swift - override._swift.hidden = true - XCTAssertEqual(override.hidden, NSNumber(value: true)) + override._swift.hide = true + XCTAssertEqual(override.hide, NSNumber(value: true)) - override._swift.hidden = false - XCTAssertEqual(override.hidden, NSNumber(value: false)) + override._swift.hide = false + XCTAssertEqual(override.hide, NSNumber(value: false)) - override._swift.hidden = nil - XCTAssertNil(override.hidden) + override._swift.hide = nil + XCTAssertNil(override.hide) // When setting hiddenPrivacy via Objective-C - override.hidden = NSNumber(value: true) - XCTAssertEqual(override._swift.hidden, true) + override.hide = NSNumber(value: true) + XCTAssertEqual(override._swift.hide, true) - override.hidden = NSNumber(value: false) - XCTAssertEqual(override._swift.hidden, false) + override.hide = NSNumber(value: false) + XCTAssertEqual(override._swift.hide, false) - override.hidden = nil - XCTAssertNil(override._swift.hidden) + override.hide = nil + XCTAssertNil(override._swift.hide) } + // MARK: Setting Overrides func testSettingAndRemovingPrivacyOverridesObjc() { // Given let override = DDSessionReplayOverride() let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! - let hidden: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil + let hide: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil // When override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy - override.hidden = hidden + override.hide = hide // Then XCTAssertEqual(override.textAndInputPrivacy, textAndInputPrivacy) XCTAssertEqual(override.imagePrivacy, imagePrivacy) XCTAssertEqual(override.touchPrivacy, touchPrivacy) - XCTAssertEqual(override.hidden, hidden) + XCTAssertEqual(override.hide, hide) // When override.textAndInputPrivacy = .none override.imagePrivacy = .none override.touchPrivacy = .none - override.hidden = false + override.hide = false // Then XCTAssertEqual(override.textAndInputPrivacy, .none) XCTAssertEqual(override.imagePrivacy, .none) XCTAssertEqual(override.touchPrivacy, .none) - XCTAssertEqual(override.hidden, false) + XCTAssertEqual(override.hide, false) } + + func testSettingAndGettingOverridesFromObjC() { + // Given + let view = UIView() + let override = DDSessionReplayOverride() + + // When + view.ddSessionReplayOverride = override + override.textAndInputPrivacy = .maskAll + override.imagePrivacy = .maskAll + override.touchPrivacy = .hide + override.hide = NSNumber(value: true) + + // Then + XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, .maskAll) + XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, .maskAll) + XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, .hide) + XCTAssertEqual(view.ddSessionReplayOverride.hide?.boolValue, true) + } + + func testClearingOverridesFromObjC() { + // Given + let view = UIView() + let override = DDSessionReplayOverride() + + // Set initial values + view.ddSessionReplayOverride = override + override.textAndInputPrivacy = .maskAll + override.imagePrivacy = .maskAll + override.touchPrivacy = .hide + override.hide = NSNumber(value: true) + + // When + view.ddSessionReplayOverride.textAndInputPrivacy = .none + view.ddSessionReplayOverride.imagePrivacy = .none + view.ddSessionReplayOverride.touchPrivacy = .none + view.ddSessionReplayOverride.hide = nil + + // Then + XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, .none) + XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, .none) + XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, .none) + XCTAssertNil(view.ddSessionReplayOverride.hide) + } } #endif diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index de8cb21200..ccda3beeb6 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -590,7 +590,7 @@ extension SessionReplayOverrideExtension: AnyMockable, RandomMockable { textAndInputPrivacy: .mockRandom(), imagePrivacy: .mockRandom(), touchPrivacy: .mockRandom(), - hidden: .mockRandom() + hide: .mockRandom() ) } @@ -598,13 +598,13 @@ extension SessionReplayOverrideExtension: AnyMockable, RandomMockable { textAndInputPrivacy: TextAndInputPrivacyLevel? = nil, imagePrivacy: ImagePrivacyLevel? = nil, touchPrivacy: TouchPrivacyLevel? = nil, - hidden: Bool? = nil + hide: Bool? = nil ) -> SessionReplayOverrideExtension { let override = SessionReplayOverrideExtension(UIView.mockRandom()) override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy - override.hidden = hidden + override.hide = hide return override } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 2357c031b4..4c7b92e9d5 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -61,7 +61,7 @@ class UIViewRecorderTests: XCTestCase { func testWhenViewHasHiddenOverride() throws { // Given viewAttributes = .mock(fixture: .visible(.someAppearance)) - viewAttributes.sessionReplayOverride = .mockWith(hidden: true) + viewAttributes.sessionReplayOverride = .mockWith(hide: true) // When let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index 566cd88f3e..efede418e3 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -188,7 +188,7 @@ class ViewAttributesTests: XCTestCase { XCTAssertNil(attributes.sessionReplayOverride?.textAndInputPrivacy) XCTAssertNil(attributes.sessionReplayOverride?.imagePrivacy) XCTAssertNil(attributes.sessionReplayOverride?.touchPrivacy) - XCTAssertNil(attributes.sessionReplayOverride?.hidden) + XCTAssertNil(attributes.sessionReplayOverride?.hide) } func testItCapturesViewAttributesWithOverrides() { @@ -198,7 +198,7 @@ class ViewAttributesTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = overrides.textAndInputPrivacy view.dd.sessionReplayOverride.imagePrivacy = overrides.imagePrivacy view.dd.sessionReplayOverride.touchPrivacy = overrides.touchPrivacy - view.dd.sessionReplayOverride.hidden = overrides.hidden + view.dd.sessionReplayOverride.hide = overrides.hide // When let attributes = ViewAttributes(frameInRootView: view.frame, view: view) @@ -207,7 +207,7 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.sessionReplayOverride?.textAndInputPrivacy, overrides.textAndInputPrivacy) XCTAssertEqual(attributes.sessionReplayOverride?.imagePrivacy, overrides.imagePrivacy) XCTAssertEqual(attributes.sessionReplayOverride?.touchPrivacy, overrides.touchPrivacy) - XCTAssertEqual(attributes.sessionReplayOverride?.hidden, overrides.hidden) + XCTAssertEqual(attributes.sessionReplayOverride?.hide, overrides.hide) } } // swiftlint:enable opening_brace diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index 5425994cd5..58c0faa01f 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -18,7 +18,7 @@ class SessionReplayOverrideTests: XCTestCase { XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hidden) + XCTAssertNil(view.dd.sessionReplayOverride.hide) } func testWithOverrides() { @@ -29,13 +29,13 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hidden = true + view.dd.sessionReplayOverride.hide = true // Then XCTAssertEqual(view.dd.sessionReplayOverride.textAndInputPrivacy, .maskAllInputs) XCTAssertEqual(view.dd.sessionReplayOverride.imagePrivacy, .maskAll) XCTAssertEqual(view.dd.sessionReplayOverride.touchPrivacy, .hide) - XCTAssertEqual(view.dd.sessionReplayOverride.hidden, true) + XCTAssertEqual(view.dd.sessionReplayOverride.hide, true) } func testRemovingOverrides() { @@ -44,19 +44,19 @@ class SessionReplayOverrideTests: XCTestCase { view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs view.dd.sessionReplayOverride.imagePrivacy = .maskAll view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hidden = true + view.dd.sessionReplayOverride.hide = true // When view.dd.sessionReplayOverride.textAndInputPrivacy = nil view.dd.sessionReplayOverride.imagePrivacy = nil view.dd.sessionReplayOverride.touchPrivacy = nil - view.dd.sessionReplayOverride.hidden = nil + view.dd.sessionReplayOverride.hide = nil // Then XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hidden) + XCTAssertNil(view.dd.sessionReplayOverride.hide) } } #endif From 12d1ac6fcebe3bd2a4627b032c41ccc93bf177f2 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 25 Sep 2024 10:25:20 +0200 Subject: [PATCH 30/43] RUM-6284 Implement overrides logic --- .../ObjcAPITests/DDSessionReplay+apiTests.m | 50 ++--- .../Sources/PrivacyLevel+SessionReplay.swift | 40 +++- .../NodeRecorders/UIDatePickerRecorder.swift | 12 +- .../NodeRecorders/UIImageViewRecorder.swift | 3 +- .../NodeRecorders/UILabelRecorder.swift | 8 +- .../NodeRecorders/UIPickerViewRecorder.swift | 6 +- .../NodeRecorders/UISegmentRecorder.swift | 6 +- .../NodeRecorders/UITextFieldRecorder.swift | 12 +- .../NodeRecorders/UITextViewRecorder.swift | 12 +- .../NodeRecorders/UIViewRecorder.swift | 4 +- .../ViewAttributes+Copy.swift | 5 +- .../ViewTreeSnapshot/ViewTreeRecorder.swift | 15 +- .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 20 +- .../Sources/SessionReplayOverrides+objc.swift | 12 +- .../Tests/DDSessionReplayOverridesTests.swift | 38 ++-- .../Tests/Mocks/RecorderMocks.swift | 22 ++- .../UIImageViewRecorderTests.swift | 28 ++- .../NodeRecorders/UILabelRecorderTests.swift | 14 ++ .../UINavigationBarRecorderTests.swift | 2 +- .../UISegmentRecorderTests.swift | 13 ++ .../NodeRecorders/UITabBarRecorderTests.swift | 4 +- .../UITextFieldRecorderTests.swift | 14 ++ .../UITextViewRecorderTests.swift | 15 ++ .../NodeRecorders/UIViewRecorderTests.swift | 2 +- .../ViewTreeRecorderTests.swift | 120 ++++++++++++ .../ViewTreeSnapshotTests.swift | 106 ++++++++--- .../Tests/SessionReplayOverrideTests.swift | 179 +++++++++++++++--- 27 files changed, 594 insertions(+), 168 deletions(-) diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index 12b4d57e13..dbf618038d 100644 --- a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m @@ -37,48 +37,48 @@ - (void)testStartAndStopRecording { [DDSessionReplay stopRecording]; } -// MARK: Overrides +// MARK: Privacy Overrides - (void)testSettingAndGettingOverrides { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayOverride *override = [[DDSessionReplayOverride alloc] init]; + DDSessionReplayOverrides *override = [[DDSessionReplayOverrides alloc] init]; // When - view.ddSessionReplayOverride = override; - view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideHide; - view.ddSessionReplayOverride.hide = @YES; + view.ddSessionReplayOverrides = override; + view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayOverrides.hide = @YES; // Then - XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideMaskAll); - XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, DDImagePrivacyLevelOverrideMaskAll); - XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, DDTouchPrivacyLevelOverrideHide); - XCTAssertTrue(view.ddSessionReplayOverride.hide.boolValue); + XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, DDImagePrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideHide); + XCTAssertTrue(view.ddSessionReplayOverrides.hide.boolValue); } - (void)testClearingOverride { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayOverride *override = [[DDSessionReplayOverride alloc] init]; + DDSessionReplayOverrides *overrides = [[DDSessionReplayOverrides alloc] init]; // Set initial values - view.ddSessionReplayOverride = override; - view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideHide; - view.ddSessionReplayOverride.hide = @YES; + view.ddSessionReplayOverrides = overrides; + view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayOverrides.hide = @YES; // When - view.ddSessionReplayOverride.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; - view.ddSessionReplayOverride.imagePrivacy = DDImagePrivacyLevelOverrideNone; - view.ddSessionReplayOverride.touchPrivacy = DDTouchPrivacyLevelOverrideNone; - view.ddSessionReplayOverride.hide = nil; + view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; + view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideNone; + view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideNone; + view.ddSessionReplayOverrides.hide = nil; // Then - XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); - XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, DDImagePrivacyLevelOverrideNone); - XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, DDTouchPrivacyLevelOverrideNone); - XCTAssertNil(view.ddSessionReplayOverride.hide); + XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, DDImagePrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideNone); + XCTAssertNil(view.ddSessionReplayOverrides.hide); } @end diff --git a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift index 7f234be6ef..0e819a664b 100644 --- a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift @@ -10,12 +10,12 @@ import DatadogInternal // MARK: - DatadogExtension for UIView -/// Extension on `DatadogExtension` to provide access to `SessionReplayOverride` for any `UIView`. +/// Extension to provide access to `SessionReplayOverrides` for any `UIView`. extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. - /// Usage: `myView.dd.sessionReplayOverride.textAndInputPrivacy = .maskNone`. - public var sessionReplayOverride: SessionReplayOverrideExtension { - return SessionReplayOverrideExtension(self.type) + /// Usage: `myView.dd.sessionReplayOverrides.textAndInputPrivacy = .maskNone`. + public var sessionReplayOverrides: SessionReplayOverrides { + return SessionReplayOverrides(self.type) } } @@ -26,10 +26,10 @@ private var associatedImagePrivacyKey: UInt8 = 4 private var associatedTouchPrivacyKey: UInt8 = 5 private var associatedHiddenPrivacyKey: UInt8 = 6 -// MARK: - SessionReplayOverrideExtension +// MARK: - SessionReplayOverrides /// `UIView` extension to manage the Session Replay privacy override settings. -public final class SessionReplayOverrideExtension { +public final class SessionReplayOverrides { private let view: UIView public init(_ view: UIView) { @@ -77,8 +77,8 @@ public final class SessionReplayOverrideExtension { } } -extension SessionReplayOverrideExtension: Equatable { - public static func == (lhs: SessionReplayOverrideExtension, rhs: SessionReplayOverrideExtension) -> Bool { +extension Overrides: Equatable { + public static func == (lhs: SessionReplayOverrides, rhs: SessionReplayOverrides) -> Bool { return lhs.view === rhs.view && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy && lhs.imagePrivacy == rhs.imagePrivacy @@ -86,4 +86,28 @@ extension SessionReplayOverrideExtension: Equatable { && lhs.hide == rhs.hide } } + +extension Overrides { + /// Merges child and parent overrides, giving precedence to the child’s overrides, if set. + /// If the child has no overrides set, it inherits its parent’s overrides. + internal static func merge(_ child: Overrides, with parent: Overrides) -> Overrides { + let merged = child + + // Apply child overrides if present + merged.textAndInputPrivacy = merged.textAndInputPrivacy ?? parent.textAndInputPrivacy + merged.imagePrivacy = merged.imagePrivacy ?? parent.imagePrivacy + merged.touchPrivacy = merged.touchPrivacy ?? parent.touchPrivacy + /// `hide` is a boolean, so we explicitly check if either the parent or the child has it set to `true`. + /// `false` and `nil` behave the same way, it deactivates the `hide` override. + /// In practice, this check should not hit, as parent views with `hide = true` should ignore their children. + if merged.hide == true || parent.hide == true { + merged.hide = true + } + + return merged + } +} + +// This alias enables us to have a more unique name exposed through public-internal access level +internal typealias Overrides = SessionReplayOverrides #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift index c70bf0ed1c..44dd0e0e8a 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIDatePickerRecorder.swift @@ -85,8 +85,8 @@ private struct WheelsStyleDatePickerRecorder { nodeRecorders: [ UIPickerViewRecorder( identifier: identifier, - textObfuscator: { context in - return context.recorder.textAndInputPrivacy.staticTextObfuscator + textObfuscator: { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).staticTextObfuscator } ) ] @@ -107,8 +107,8 @@ private struct InlineStyleDatePickerRecorder { self.viewRecorder = UIViewRecorder(identifier: identifier) self.labelRecorder = UILabelRecorder( identifier: identifier, - textObfuscator: { context in - return context.recorder.textAndInputPrivacy.staticTextObfuscator + textObfuscator: { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).staticTextObfuscator } ) self.subtreeRecorder = ViewTreeRecorder( @@ -154,8 +154,8 @@ private struct CompactStyleDatePickerRecorder { UIViewRecorder(identifier: identifier), UILabelRecorder( identifier: identifier, - textObfuscator: { context in - return context.recorder.textAndInputPrivacy.staticTextObfuscator + textObfuscator: { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).staticTextObfuscator } ) ] diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift index 5546db24bc..2e05cd2a29 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorder.swift @@ -44,6 +44,7 @@ internal struct UIImageViewRecorder: NodeRecorder { if let semantics = semanticsOverride(imageView, attributes) { return semantics } + guard attributes.hasAnyAppearance || imageView.image != nil else { return InvisibleElement.constant } @@ -59,7 +60,7 @@ internal struct UIImageViewRecorder: NodeRecorder { let shouldRecordImage = if let shouldRecordImagePredicateOverride { shouldRecordImagePredicateOverride(imageView) } else { - context.recorder.imagePrivacy.shouldRecordImagePredicate(imageView) + attributes.resolveImagePrivacyLevel(in: context).shouldRecordImagePredicate(imageView) } let imageResource = shouldRecordImage ? imageView.image.map { image in UIImageResource(image: image, tintColor: tintColorProvider(imageView)) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift index 4965df9d9d..a6f16ae961 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorder.swift @@ -12,13 +12,13 @@ internal class UILabelRecorder: NodeRecorder { /// An option for customizing wireframes builder created by this recorder. var builderOverride: (UILabelWireframesBuilder) -> UILabelWireframesBuilder - var textObfuscator: (ViewTreeRecordingContext) -> TextObfuscating + var textObfuscator: (ViewTreeRecordingContext, ViewAttributes) -> TextObfuscating init( identifier: UUID, builderOverride: @escaping (UILabelWireframesBuilder) -> UILabelWireframesBuilder = { $0 }, - textObfuscator: @escaping (ViewTreeRecordingContext) -> TextObfuscating = { context in - return context.recorder.textAndInputPrivacy.staticTextObfuscator + textObfuscator: @escaping (ViewTreeRecordingContext, ViewAttributes) -> TextObfuscating = { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).staticTextObfuscator } ) { self.identifier = identifier @@ -45,7 +45,7 @@ internal class UILabelRecorder: NodeRecorder { textAlignment: label.textAlignment, font: label.font, fontScalingEnabled: label.adjustsFontSizeToFitWidth, - textObfuscator: textObfuscator(context) + textObfuscator: textObfuscator(context, attributes) ) let node = Node(viewAttributes: attributes, wireframesBuilder: builderOverride(builder)) return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift index c2092d4937..394635d579 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIPickerViewRecorder.swift @@ -11,7 +11,7 @@ import UIKit /// /// The look of picker view in SR is approximated by capturing the text from "selected row" and ignoring all other values on the wheel: /// - If the picker defines multiple components, there will be multiple selected values. -/// - We can't request `picker.dataSource` to receive the value - doing so will result in calling applicaiton code, which could be +/// - We can't request `picker.dataSource` to receive the value - doing so will result in calling application code, which could be /// dangerous (if the code is faulty) and may significantly slow down the performance (e.g. if the underlying source requires database fetch). /// - Similarly, we don't call `picker.delegate` to avoid running application code outside `UIKit's` lifecycle. /// - Instead, we infer the value by traversing picker's subtree and finding texts that have no "3D wheel" effect applied. @@ -28,8 +28,8 @@ internal struct UIPickerViewRecorder: NodeRecorder { init( identifier: UUID, - textObfuscator: @escaping (ViewTreeRecordingContext) -> TextObfuscating = { context in - return context.recorder.textAndInputPrivacy.inputAndOptionTextObfuscator + textObfuscator: @escaping (ViewTreeRecordingContext, ViewAttributes) -> TextObfuscating = { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).inputAndOptionTextObfuscator } ) { self.identifier = identifier diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift index 761b697a8e..160bb17580 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorder.swift @@ -13,8 +13,8 @@ internal struct UISegmentRecorder: NodeRecorder { init(identifier: UUID) { self.identifier = identifier } - var textObfuscator: (ViewTreeRecordingContext) -> TextObfuscating = { context in - return context.recorder.textAndInputPrivacy.inputAndOptionTextObfuscator + var textObfuscator: (ViewTreeRecordingContext, ViewAttributes) -> TextObfuscating = { context, viewAttributes in + return viewAttributes.resolveTextAndInputPrivacyLevel(in: context).inputAndOptionTextObfuscator } func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { @@ -31,7 +31,7 @@ internal struct UISegmentRecorder: NodeRecorder { let builder = UISegmentWireframesBuilder( wireframeRect: attributes.frame, attributes: attributes, - textObfuscator: textObfuscator(context), + textObfuscator: textObfuscator(context, attributes), backgroundWireframeID: ids[0], segmentWireframeIDs: Array(ids[1.. TextObfuscating = { context, isSensitive, isPlaceholder in + var textObfuscator: (ViewTreeRecordingContext, _ viewAttributes: ViewAttributes, _ isSensitive: Bool, _ isPlaceholder: Bool) -> TextObfuscating = { context, viewAttributes, isSensitive, isPlaceholder in + let resolvedPrivacyLevel = viewAttributes.resolveTextAndInputPrivacyLevel(in: context) + if isPlaceholder { - return context.recorder.textAndInputPrivacy.hintTextObfuscator + return resolvedPrivacyLevel.hintTextObfuscator } else if isSensitive { - return context.recorder.textAndInputPrivacy.sensitiveTextObfuscator + return resolvedPrivacyLevel.sensitiveTextObfuscator } else { - return context.recorder.textAndInputPrivacy.inputAndOptionTextObfuscator + return resolvedPrivacyLevel.inputAndOptionTextObfuscator } } @@ -95,7 +97,7 @@ internal struct UITextFieldRecorder: NodeRecorder { isPlaceholderText: isPlaceholder, font: textField.font, fontScalingEnabled: textField.adjustsFontSizeToFitWidth, - textObfuscator: textObfuscator(context, textField.dd.isSensitiveText, isPlaceholder) + textObfuscator: textObfuscator(context, attributes, textField.dd.isSensitiveText, isPlaceholder) ) return Node(viewAttributes: attributes, wireframesBuilder: builder) } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift index 01e6c0c580..2b4a5297e3 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorder.swift @@ -14,15 +14,17 @@ internal struct UITextViewRecorder: NodeRecorder { self.identifier = identifier } - var textObfuscator: (ViewTreeRecordingContext, _ isSensitive: Bool, _ isEditable: Bool) -> TextObfuscating = { context, isSensitive, isEditable in + var textObfuscator: (ViewTreeRecordingContext, _ viewAttributes: ViewAttributes, _ isSensitive: Bool, _ isEditable: Bool) -> TextObfuscating = { context, viewAttributes, isSensitive, isEditable in + let resolvedPrivacyLevel = viewAttributes.resolveTextAndInputPrivacyLevel(in: context) + if isSensitive { - return context.recorder.textAndInputPrivacy.sensitiveTextObfuscator + return resolvedPrivacyLevel.sensitiveTextObfuscator } if isEditable { - return context.recorder.textAndInputPrivacy.inputAndOptionTextObfuscator + return resolvedPrivacyLevel.inputAndOptionTextObfuscator } else { - return context.recorder.textAndInputPrivacy.staticTextObfuscator + return resolvedPrivacyLevel.staticTextObfuscator } } @@ -41,7 +43,7 @@ internal struct UITextViewRecorder: NodeRecorder { textAlignment: textView.textAlignment, textColor: textView.textColor?.cgColor ?? UIColor.black.cgColor, font: textView.font, - textObfuscator: textObfuscator(context, textView.dd.isSensitiveText, textView.isEditable), + textObfuscator: textObfuscator(context, attributes, textView.dd.isSensitiveText, textView.isEditable), contentRect: CGRect(origin: textView.contentOffset, size: textView.contentSize) ) let node = Node(viewAttributes: attributes, wireframesBuilder: builder) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift index 76966fb994..2a677184d4 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorder.swift @@ -41,7 +41,7 @@ internal class UIViewRecorder: NodeRecorder { return semantics } - if attributes.sessionReplayOverride?.hide == true { + if attributes.overrides.hide == true { let builder = UIViewWireframesBuilder( wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), attributes: attributes @@ -75,7 +75,7 @@ internal struct UIViewWireframesBuilder: NodeWireframesBuilder { } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { - if attributes.sessionReplayOverride?.hide == true { + if attributes.overrides.hide == true { return [ builder.createPlaceholderWireframe(id: wireframeID, frame: wireframeRect, label: "Hidden") ] diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift index 08730ff90c..c02769c969 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift @@ -25,6 +25,7 @@ extension ViewAttributes { var alpha: CGFloat var isHidden: Bool var intrinsicContentSize: CGSize + var overrides: Overrides fileprivate init(original: ViewAttributes) { self.frame = original.frame @@ -35,6 +36,7 @@ extension ViewAttributes { self.alpha = original.alpha self.isHidden = original.isHidden self.intrinsicContentSize = original.intrinsicContentSize + self.overrides = original.overrides } fileprivate func toViewAttributes() -> ViewAttributes { @@ -46,7 +48,8 @@ extension ViewAttributes { layerCornerRadius: layerCornerRadius, alpha: alpha, isHidden: isHidden, - intrinsicContentSize: intrinsicContentSize + intrinsicContentSize: intrinsicContentSize, + overrides: overrides ) } } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift index 12f7d09989..d075f1a05f 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -17,7 +17,7 @@ internal struct ViewTreeRecorder { /// Creates `Nodes` for given view and its subtree hierarchy. func record(_ anyView: UIView, in context: ViewTreeRecordingContext) -> [Node] { var nodes: [Node] = [] - recordRecursively(nodes: &nodes, view: anyView, context: context) + recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd.sessionReplayOverrides) return nodes } @@ -26,7 +26,8 @@ internal struct ViewTreeRecorder { private func recordRecursively( nodes: inout [Node], view: UIView, - context: ViewTreeRecordingContext + context: ViewTreeRecordingContext, + overrides: Overrides ) { var context = context if let viewController = view.next as? UIViewController { @@ -36,7 +37,7 @@ internal struct ViewTreeRecorder { context.viewControllerContext.isRootView = false } - let semantics = nodeSemantics(for: view, in: context) + let semantics = nodeSemantics(for: view, in: context, overrides: overrides) if !semantics.nodes.isEmpty { nodes.append(contentsOf: semantics.nodes) @@ -45,17 +46,19 @@ internal struct ViewTreeRecorder { switch semantics.subtreeStrategy { case .record: for subview in view.subviews { - recordRecursively(nodes: &nodes, view: subview, context: context) + let subviewOverrides = SessionReplayOverrides.merge(subview.dd.sessionReplayOverrides, with: overrides) + recordRecursively(nodes: &nodes, view: subview, context: context, overrides: subviewOverrides) } case .ignore: break } } - private func nodeSemantics(for view: UIView, in context: ViewTreeRecordingContext) -> NodeSemantics { + private func nodeSemantics(for view: UIView, in context: ViewTreeRecordingContext, overrides: Overrides) -> NodeSemantics { let attributes = ViewAttributes( frameInRootView: view.convert(view.bounds, to: context.coordinateSpace), - view: view + view: view, + overrides: overrides ) var semantics: NodeSemantics = UnknownElement.constant diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 2f076fc447..4c0a1277dc 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -125,14 +125,14 @@ public struct SessionReplayViewAttributes: Equatable { var isTranslucent: Bool { !isVisible || alpha < 1 || backgroundColor?.alpha ?? 0 < 1 } /// If the view has privacy overrides, which take precedence over global masking privacy levels. - var sessionReplayOverride: SessionReplayOverrideExtension? + var overrides: Overrides } // 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) { + init(frameInRootView: CGRect, view: UIView, overrides: SessionReplayOverrides) { self.frame = frameInRootView self.backgroundColor = view.backgroundColor?.cgColor.safeCast self.layerBorderColor = view.layer.borderColor?.safeCast @@ -141,7 +141,21 @@ extension ViewAttributes { self.alpha = view.alpha self.isHidden = view.isHidden self.intrinsicContentSize = view.intrinsicContentSize - self.sessionReplayOverride = view.dd.sessionReplayOverride + self.overrides = overrides + } +} + +extension ViewAttributes { + /// Resolves the effective privacy level for text and input elements by considering the view's local override. + /// Falls back to the global privacy setting in the absence of local overrides. + func resolveTextAndInputPrivacyLevel(in context: ViewTreeRecordingContext) -> TextAndInputPrivacyLevel { + return self.overrides.textAndInputPrivacy ?? context.recorder.textAndInputPrivacy + } + + /// Resolves the effective privacy level for image elements by considering the view's local override. + /// Falls back to the global privacy setting in the absence of local overrides. + func resolveImagePrivacyLevel(in context: ViewTreeRecordingContext) -> ImagePrivacyLevel { + return self.overrides.imagePrivacy ?? context.recorder.imagePrivacy } } diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index db6b32ec18..68ae38a26a 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -14,12 +14,12 @@ private var associatedSROverrideKey: UInt8 = 0 /// Objective-C accessible extension for UIView @objc public extension UIView { - @objc var ddSessionReplayOverride: DDSessionReplayOverride { + @objc var ddSessionReplayOverrides: DDSessionReplayOverrides { get { - if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayOverride { + if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayOverrides { return override } else { - let override = DDSessionReplayOverride() + let override = DDSessionReplayOverrides() objc_setAssociatedObject(self, &associatedSROverrideKey, override, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) return override } @@ -32,13 +32,13 @@ public extension UIView { /// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. @objc -public final class DDSessionReplayOverride: NSObject { +public final class DDSessionReplayOverrides: NSObject { /// Internal Swift equivalent of the Session Replay Override, tied to the view. - internal var _swift: SessionReplayOverrideExtension + internal var _swift: SessionReplayOverrides @objc override public init() { - _swift = SessionReplayOverrideExtension(UIView()) + _swift = SessionReplayOverrides(UIView()) super.init() } diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index f3fcbfc653..bd51367462 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -13,7 +13,7 @@ import DatadogInternal @testable import DatadogSessionReplay class DDSessionReplayOverrideTests: XCTestCase { - // MARK: Overrides Interoperability + // MARK: Privacy Overrides Interoperability func testTextAndInputPrivacyLevelsOverrideInterop() { XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAll._swift, .maskAll) XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAllInputs._swift, .maskAllInputs) @@ -49,7 +49,7 @@ class DDSessionReplayOverrideTests: XCTestCase { } func testHiddenPrivacyLevelsOverrideInterop() { - let override = DDSessionReplayOverride() + let override = DDSessionReplayOverrides() // When setting hiddenPrivacy via Swift override._swift.hide = true @@ -75,7 +75,7 @@ class DDSessionReplayOverrideTests: XCTestCase { // MARK: Setting Overrides func testSettingAndRemovingPrivacyOverridesObjc() { // Given - let override = DDSessionReplayOverride() + let override = DDSessionReplayOverrides() let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! @@ -109,45 +109,45 @@ class DDSessionReplayOverrideTests: XCTestCase { func testSettingAndGettingOverridesFromObjC() { // Given let view = UIView() - let override = DDSessionReplayOverride() + let override = DDSessionReplayOverrides() // When - view.ddSessionReplayOverride = override + view.ddSessionReplayOverrides = override override.textAndInputPrivacy = .maskAll override.imagePrivacy = .maskAll override.touchPrivacy = .hide override.hide = NSNumber(value: true) // Then - XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, .maskAll) - XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, .maskAll) - XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, .hide) - XCTAssertEqual(view.ddSessionReplayOverride.hide?.boolValue, true) + XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, .maskAll) + XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, .maskAll) + XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, .hide) + XCTAssertEqual(view.ddSessionReplayOverrides.hide?.boolValue, true) } func testClearingOverridesFromObjC() { // Given let view = UIView() - let override = DDSessionReplayOverride() + let override = DDSessionReplayOverrides() // Set initial values - view.ddSessionReplayOverride = override + view.ddSessionReplayOverrides = override override.textAndInputPrivacy = .maskAll override.imagePrivacy = .maskAll override.touchPrivacy = .hide override.hide = NSNumber(value: true) // When - view.ddSessionReplayOverride.textAndInputPrivacy = .none - view.ddSessionReplayOverride.imagePrivacy = .none - view.ddSessionReplayOverride.touchPrivacy = .none - view.ddSessionReplayOverride.hide = nil + view.ddSessionReplayOverrides.textAndInputPrivacy = .none + view.ddSessionReplayOverrides.imagePrivacy = .none + view.ddSessionReplayOverrides.touchPrivacy = .none + view.ddSessionReplayOverrides.hide = nil // Then - XCTAssertEqual(view.ddSessionReplayOverride.textAndInputPrivacy, .none) - XCTAssertEqual(view.ddSessionReplayOverride.imagePrivacy, .none) - XCTAssertEqual(view.ddSessionReplayOverride.touchPrivacy, .none) - XCTAssertNil(view.ddSessionReplayOverride.hide) + XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, .none) + XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, .none) + XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, .none) + XCTAssertNil(view.ddSessionReplayOverrides.hide) } } #endif diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index ccda3beeb6..a3c5d78f6b 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -64,7 +64,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: .mockRandom(min: 0, max: 5), alpha: .mockRandom(min: 0, max: 1), isHidden: .mockRandom(), - intrinsicContentSize: .mockRandom() + intrinsicContentSize: .mockRandom(), + overrides: .mockAny() ) } @@ -78,7 +79,7 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: CGFloat = .mockAny(), isHidden: Bool = .mockAny(), intrinsicContentSize: CGSize = .mockAny(), - sessionReplayOverride: SessionReplayOverrideExtension? = .mockAny() + overrides: Overrides = .mockAny() ) -> ViewAttributes { return .init( frame: frame, @@ -89,7 +90,7 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: alpha, isHidden: isHidden, intrinsicContentSize: intrinsicContentSize, - sessionReplayOverride: sessionReplayOverride + overrides: overrides ) } @@ -173,7 +174,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: .mockRandom(min: 0, max: 4), alpha: alpha ?? .mockRandom(min: 0.01, max: 1), isHidden: isHidden ?? .mockRandom(), - intrinsicContentSize: (frame ?? .mockRandom(minWidth: 10, minHeight: 10)).size + intrinsicContentSize: (frame ?? .mockRandom(minWidth: 10, minHeight: 10)).size, + overrides: .mockAny() ) // consistency check: @@ -529,7 +531,7 @@ internal func mockUIView(with attributes: ViewAttributes) -> View // Consistency check - to make sure computed properties in `ViewAttributes` captured // for mocked view are equal the these from requested `attributes`. let expectedAttributes = attributes - let actualAttributes = ViewAttributes(frameInRootView: view.frame, view: view) + let actualAttributes = ViewAttributes(frameInRootView: view.frame, view: view, overrides: .mockAny()) assert( actualAttributes.isVisible == expectedAttributes.isVisible, @@ -580,12 +582,12 @@ internal extension Optional where Wrapped == NodeSemantics { } } -extension SessionReplayOverrideExtension: AnyMockable, RandomMockable { - public static func mockAny() -> SessionReplayOverrideExtension { +extension Overrides: AnyMockable, RandomMockable { + public static func mockAny() -> Overrides { return mockWith() } - public static func mockRandom() -> SessionReplayOverrideExtension { + public static func mockRandom() -> Overrides { return mockWith( textAndInputPrivacy: .mockRandom(), imagePrivacy: .mockRandom(), @@ -599,8 +601,8 @@ extension SessionReplayOverrideExtension: AnyMockable, RandomMockable { imagePrivacy: ImagePrivacyLevel? = nil, touchPrivacy: TouchPrivacyLevel? = nil, hide: Bool? = nil - ) -> SessionReplayOverrideExtension { - let override = SessionReplayOverrideExtension(UIView.mockRandom()) + ) -> Overrides { + let override = Overrides(UIView.mockRandom()) override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift index 134ce11250..565f7e4595 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift @@ -18,6 +18,7 @@ class UIImageViewRecorderTests: XCTestCase { /// `ViewAttributes` simulating common attributes of image view's `UIView`. private var viewAttributes: ViewAttributes = .mockAny() + // MARK: Appearance func testWhenImageViewHasNoImageAndNoAppearance() throws { // When imageView.image = nil @@ -53,6 +54,15 @@ class UIImageViewRecorderTests: XCTestCase { XCTAssertTrue(semantics.nodes.first?.wireframesBuilder is UIImageViewWireframesBuilder) } + func testWhenViewIsNotOfExpectedType() { + // When + let view = UITextField() + + // Then + XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) + } + + // MARK: Predicate Override func testWhenShouldRecordImagePredicateOverrideReturnsFalse() throws { // When let recorder = UIImageViewRecorder(identifier: UUID(), shouldRecordImagePredicateOverride: { _ in return false }) @@ -81,6 +91,7 @@ class UIImageViewRecorderTests: XCTestCase { XCTAssertNotNil(builder.imageResource) } + // MARK: Image Privacy func testWhenMaskAllImagePrivacy_itDoesNotRecordImage() throws { // Given let imagePrivacy = ImagePrivacyLevel.maskAll @@ -137,12 +148,23 @@ class UIImageViewRecorderTests: XCTestCase { XCTAssertNotNil(builder.imageResource) } - func testWhenViewIsNotOfExpectedType() { + // MARK: Privacy Overrides + func testWhenImageViewHasImagePrivacyOverride() throws { + // Given + let globalImagePrivacy = ImagePrivacyLevel.maskNone + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(imagePrivacy: globalImagePrivacy)) + + imageView.image = UIImage() + let overrideImagePrivacy: ImagePrivacyLevel = .maskAll + let overrides: Overrides = .mockWith(imagePrivacy: overrideImagePrivacy) + viewAttributes = .mockWith(overrides: overrides) + // When - let view = UITextField() + let semantics = try XCTUnwrap(recorder.semantics(of: imageView, with: viewAttributes, in: context)) // Then - XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UIImageViewWireframesBuilder) + XCTAssertNil(builder.imageResource) } } // swiftlint:enable opening_brace diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift index 09922f0169..fdbc812800 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UILabelRecorderTests.swift @@ -85,6 +85,20 @@ class UILabelRecorderTests: XCTestCase { XCTAssertTrue(try textObfuscator(in: .maskAllInputs) is NOPTextObfuscator) XCTAssertTrue(try textObfuscator(in: .maskAll) is SpacePreservingMaskObfuscator) } + + func testWhenLabelHasTextPrivacyOverride() throws { + // Given + label.text = .mockRandom() + viewAttributes = .mock(fixture: .visible()) + viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + + // When + let semantics = try XCTUnwrap(recorder.semantics(of: label, with: viewAttributes, in: .mockAny()) as? SpecificElement) + + // Then + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UILabelWireframesBuilder) + XCTAssertTrue(builder.textObfuscator is SpacePreservingMaskObfuscator) + } } // swiftlint:enable opening_brace #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift index 06434cdd53..0b890914fb 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UINavigationBarRecorderTests.swift @@ -21,7 +21,7 @@ class UINavigationBarRecorderTests: XCTestCase { ] let navigationBar = UINavigationBar.mock(withFixture: fixtures.randomElement()!) - let viewAttributes = ViewAttributes(frameInRootView: navigationBar.frame, view: navigationBar) + let viewAttributes = ViewAttributes(frameInRootView: navigationBar.frame, view: navigationBar, overrides: .mockAny()) // When let semantics = try XCTUnwrap(recorder.semantics(of: navigationBar, with: viewAttributes, in: .mockAny()) as? SpecificElement) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift index ab6d07466d..db93eca76c 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UISegmentRecorderTests.swift @@ -54,5 +54,18 @@ class UISegmentRecorderTests: XCTestCase { // Then XCTAssertNil(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) } + + func testWhenSegmentHasTextPrivacyOverride() throws { + // Given + viewAttributes = .mock(fixture: .visible()) + viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + + // When + let semantics = try XCTUnwrap(recorder.semantics(of: segment, with: viewAttributes, in: .mockAny()) as? SpecificElement) + + // Then + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UISegmentWireframesBuilder) + XCTAssertTrue(builder.textObfuscator is FixLengthMaskObfuscator) + } } #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift index 8786502bc1..41b6dd7b6a 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITabBarRecorderTests.swift @@ -17,7 +17,7 @@ class UITabBarRecorderTests: XCTestCase { func testWhenViewIsOfExpectedType() throws { // When let tabBar = UITabBar.mock(withFixture: .allCases.randomElement()!) - let viewAttributes = ViewAttributes(frameInRootView: tabBar.frame, view: tabBar) + let viewAttributes = ViewAttributes(frameInRootView: tabBar.frame, view: tabBar, overrides: .mockAny()) // Then let semantics = try XCTUnwrap(recorder.semantics(of: tabBar, with: viewAttributes, in: .mockAny())) @@ -38,7 +38,7 @@ class UITabBarRecorderTests: XCTestCase { // Given let tabBar = UITabBar.mock(withFixture: .visible(.someAppearance)) tabBar.items = [UITabBarItem(title: "first", image: UIImage(), tag: 0)] - let viewAttributes = ViewAttributes(frameInRootView: tabBar.frame, view: tabBar) + let viewAttributes = ViewAttributes(frameInRootView: tabBar.frame, view: tabBar, overrides: .mockAny()) // When let semantics1 = recorder.semantics(of: tabBar, with: viewAttributes, in: .mockAny()) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift index 2c3a48e1e3..8402ec5a61 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextFieldRecorderTests.swift @@ -107,6 +107,20 @@ class UITextFieldRecorderTests: XCTestCase { // Then - it keeps obfuscating XCTAssertTrue(try textObfuscator(in: .mockRandom()) is FixLengthMaskObfuscator) } + + func testWhenTextFieldHasTextPrivacyOverride() throws { + // Given + textField.text = .mockRandom() + viewAttributes = .mock(fixture: .visible()) + viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + + // When + let semantics = try XCTUnwrap(recorder.semantics(of: textField, with: viewAttributes, in: .mockAny()) as? SpecificElement) + + // Then + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UITextFieldWireframesBuilder) + XCTAssertTrue(builder.textObfuscator is FixLengthMaskObfuscator) + } } // swiftlint:enable opening_brace #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift index 6520f9cdef..1e3e70ecc7 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UITextViewRecorderTests.swift @@ -98,6 +98,21 @@ class UITextViewRecorderTests: XCTestCase { // Then - it keeps obfuscating XCTAssertTrue(try textObfuscator(in: .mockRandom()) is FixLengthMaskObfuscator) } + + func testWhenTextViewHasTextPrivacyOverride() throws { + // Given + textView.text = .mockRandom() + textView.isEditable = false + viewAttributes = .mock(fixture: .visible()) + viewAttributes.overrides = .mockWith(textAndInputPrivacy: .maskAll) + + // When + let semantics = try XCTUnwrap(recorder.semantics(of: textView, with: viewAttributes, in: .mockAny()) as? SpecificElement) + + // Then + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? UITextViewWireframesBuilder) + XCTAssertTrue(builder.textObfuscator is SpacePreservingMaskObfuscator) + } } // swiftlint:enable opening_brace #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift index 4c7b92e9d5..814802b420 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIViewRecorderTests.swift @@ -61,7 +61,7 @@ class UIViewRecorderTests: XCTestCase { func testWhenViewHasHiddenOverride() throws { // Given viewAttributes = .mock(fixture: .visible(.someAppearance)) - viewAttributes.sessionReplayOverride = .mockWith(hide: true) + viewAttributes.overrides = .mockWith(hide: true) // When let semantics = try XCTUnwrap(recorder.semantics(of: view, with: viewAttributes, in: .mockAny())) diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index 2bf792b14c..c4b1c5e090 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift @@ -294,5 +294,125 @@ class ViewTreeRecorderTests: XCTestCase { XCTAssertEqual(context?.viewControllerContext.isRootView, false) XCTAssertEqual(context?.viewControllerContext.parentType, .activity) } + + // MARK: Privacy Overrides + + func testChildViewInheritsParentHideOverride() { + // Given + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + parentView.dd.sessionReplayOverrides.hide = true + + // When + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 1) + } + + func testChildViewHideOverrideIsTrueAndParentHideOverrideIsFalse() { + // Given + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + childView.dd.sessionReplayOverrides.hide = true + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + parentView.dd.sessionReplayOverrides.hide = false + + // When + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 2) + } + + func testChildViewHideOverrideIsTrueAndParentHideOverrideIsNil() { + // Given + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + childView.dd.sessionReplayOverrides.hide = true + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + + // When + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 2) + } + + func testImageViewOverrideTakesPrecedenceOverGlobalSetting() { + // Given + let globalImagePrivacy: ImagePrivacyLevel = .mockRandom() + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(imagePrivacy: globalImagePrivacy)) + + let viewImagePrivacy: ImagePrivacyLevel = .maskNone + let imageView = UIImageView.mock(withFixture: .visible(.someAppearance)) + imageView.image = .mockRandom() + imageView.dd.sessionReplayOverrides.imagePrivacy = viewImagePrivacy + + // When + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let nodes = recorder.record(imageView, in: context) + + // Then + XCTAssertEqual(nodes.count, 1) + let node = nodes.first + XCTAssertNotNil(node) + XCTAssertEqual(node!.viewAttributes.overrides.imagePrivacy, viewImagePrivacy) + let builder = node!.wireframesBuilder as? UIImageViewWireframesBuilder + XCTAssertNotNil(builder) + XCTAssertNotNil(builder!.imageResource) + } + + func testChildViewMaskNoneImageOverrideWithParentImageOverride() { + // Given + let childImagePrivacy: ImagePrivacyLevel = .maskNone + let childView = UIImageView.mock(withFixture: .visible(.someAppearance)) + childView.image = .mockRandom() + childView.dd.sessionReplayOverrides.imagePrivacy = childImagePrivacy + let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.dd.sessionReplayOverrides.imagePrivacy = parentImagePrivacy + parentView.addSubview(childView) + + // When + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 2) + XCTAssertEqual(nodes[0].viewAttributes.overrides.imagePrivacy, parentImagePrivacy) + XCTAssertEqual(nodes[1].viewAttributes.overrides.imagePrivacy, childImagePrivacy) + let builder = nodes[1].wireframesBuilder as? UIImageViewWireframesBuilder + XCTAssertNotNil(builder) + XCTAssertNotNil(builder!.imageResource) + } + + func testChildViewMaskAllImageOverrideWithParentImageOverride() { + // Given + let childImagePrivacy: ImagePrivacyLevel = .maskAll + let childView = UIImageView.mock(withFixture: .visible(.someAppearance)) + childView.image = .mockRandom() + childView.dd.sessionReplayOverrides.imagePrivacy = childImagePrivacy + let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.dd.sessionReplayOverrides.imagePrivacy = parentImagePrivacy + parentView.addSubview(childView) + + // When + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 2) + XCTAssertEqual(nodes[0].viewAttributes.overrides.imagePrivacy, parentImagePrivacy) + XCTAssertEqual(nodes[1].viewAttributes.overrides.imagePrivacy, childImagePrivacy) + let builder = nodes[1].wireframesBuilder as? UIImageViewWireframesBuilder + XCTAssertNotNil(builder) + XCTAssertNil(builder!.imageResource) + } } #endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index efede418e3..56754a8fae 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -12,12 +12,13 @@ import XCTest // swiftlint:disable opening_brace class ViewAttributesTests: XCTestCase { + // MARK: Appearance func testItCapturesViewAttributes() { // Given - let view: UIView = .mockRandom() + let view = UIView.mockRandom() // When - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) // Then XCTAssertEqual(attributes.frame, view.frame) @@ -28,6 +29,10 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.alpha, view.alpha) XCTAssertEqual(attributes.isHidden, view.isHidden) XCTAssertEqual(attributes.intrinsicContentSize, view.intrinsicContentSize) + XCTAssertNil(attributes.overrides.textAndInputPrivacy) + XCTAssertNil(attributes.overrides.imagePrivacy) + XCTAssertNil(attributes.overrides.touchPrivacy) + XCTAssertNil(attributes.overrides.hide) } func testWhenViewIsVisible() { @@ -40,7 +45,7 @@ class ViewAttributesTests: XCTestCase { view.frame = .mockRandom(minWidth: 0.01, minHeight: 0.01) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertTrue(attributes.isVisible) } @@ -56,7 +61,7 @@ class ViewAttributesTests: XCTestCase { ]) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertFalse(attributes.isVisible) } @@ -79,7 +84,7 @@ class ViewAttributesTests: XCTestCase { ]) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertTrue(attributes.hasAnyAppearance) } @@ -102,7 +107,7 @@ class ViewAttributesTests: XCTestCase { ]) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertFalse(attributes.hasAnyAppearance) } @@ -119,7 +124,7 @@ class ViewAttributesTests: XCTestCase { ]) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertTrue(attributes.isTranslucent) } @@ -134,7 +139,7 @@ class ViewAttributesTests: XCTestCase { view.backgroundColor = .mockRandomWith(alpha: 1) // Then - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) XCTAssertFalse(attributes.isTranslucent) } @@ -144,7 +149,7 @@ class ViewAttributesTests: XCTestCase { view.setValue("invalid color", forKeyPath: "layer.borderColor") // When - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) // Then XCTAssertNil(attributes.layerBorderColor) @@ -156,7 +161,8 @@ class ViewAttributesTests: XCTestCase { let color: CGColor = .mockRandom() let float: CGFloat = .mockRandom() let boolean: Bool = .mockRandom() - let attributes = ViewAttributes(frameInRootView: view.frame, view: view).copy { + let overrides: Overrides = .mockRandom() + let attributes = ViewAttributes(frameInRootView: view.frame, view: view, overrides: overrides).copy { $0.frame = rect $0.backgroundColor = color $0.layerBorderColor = color @@ -165,6 +171,7 @@ class ViewAttributesTests: XCTestCase { $0.alpha = float $0.isHidden = boolean $0.intrinsicContentSize = rect.size + $0.overrides = overrides } XCTAssertEqual(attributes.frame, rect) XCTAssertEqual(attributes.backgroundColor, color) @@ -174,40 +181,75 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.alpha, float) XCTAssertEqual(attributes.isHidden, boolean) XCTAssertEqual(attributes.intrinsicContentSize, rect.size) + XCTAssertEqual(attributes.overrides, overrides) } - // MARK: Overrides + // MARK: Privacy Overrides + func testItDefaultsToNilWhenNoOverrideIsSet() { // Given - let view: UIView = .mockRandom() + let view: UIView = .mockAny() // When - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let attributes = createViewAttributes(with: view) // Then - XCTAssertNil(attributes.sessionReplayOverride?.textAndInputPrivacy) - XCTAssertNil(attributes.sessionReplayOverride?.imagePrivacy) - XCTAssertNil(attributes.sessionReplayOverride?.touchPrivacy) - XCTAssertNil(attributes.sessionReplayOverride?.hide) + XCTAssertNil(attributes.overrides.textAndInputPrivacy) + XCTAssertNil(attributes.overrides.imagePrivacy) + XCTAssertNil(attributes.overrides.touchPrivacy) + XCTAssertNil(attributes.overrides.hide) } - func testItCapturesViewAttributesWithOverrides() { + func testChildViewInheritsParentHideOverride() { // Given - let view: UIView = .mockRandom() - let overrides = SessionReplayOverrideExtension.mockRandom() - view.dd.sessionReplayOverride.textAndInputPrivacy = overrides.textAndInputPrivacy - view.dd.sessionReplayOverride.imagePrivacy = overrides.imagePrivacy - view.dd.sessionReplayOverride.touchPrivacy = overrides.touchPrivacy - view.dd.sessionReplayOverride.hide = overrides.hide + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + parentView.dd.sessionReplayOverrides.hide = true + + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + + // When + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 1) + } + + func testChildViewHideOverrideSetToFalseDoesNotOverrideParentHideOverride() { + // Given + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + + parentView.dd.sessionReplayOverrides.hide = true + childView.dd.sessionReplayOverrides.hide = false + + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) // When - let attributes = ViewAttributes(frameInRootView: view.frame, view: view) + let nodes = recorder.record(parentView, in: .mockRandom()) + + // Then + XCTAssertEqual(nodes.count, 1, "Child view overrides parent's hidden state, so it should be recorded.") + } + + func testChildViewInheritsParentOverrides() { + // Given + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + + let parentOverrides: Overrides = .mockRandom() + parentView.dd.sessionReplayOverrides.textAndInputPrivacy = parentOverrides.textAndInputPrivacy + parentView.dd.sessionReplayOverrides.imagePrivacy = parentOverrides.imagePrivacy + parentView.dd.sessionReplayOverrides.touchPrivacy = parentOverrides.touchPrivacy + + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + let nodes = recorder.record(parentView, in: .mockRandom()) // Then - XCTAssertEqual(attributes.sessionReplayOverride?.textAndInputPrivacy, overrides.textAndInputPrivacy) - XCTAssertEqual(attributes.sessionReplayOverride?.imagePrivacy, overrides.imagePrivacy) - XCTAssertEqual(attributes.sessionReplayOverride?.touchPrivacy, overrides.touchPrivacy) - XCTAssertEqual(attributes.sessionReplayOverride?.hide, overrides.hide) + XCTAssertEqual(nodes.count, 2) } } // swiftlint:enable opening_brace @@ -252,4 +294,10 @@ class NodeSemanticsTests: XCTestCase { ) } } + +extension ViewAttributesTests { + func createViewAttributes(with view: UIView) -> ViewAttributes { + return ViewAttributes(frameInRootView: view.frame, view: view, overrides: .mockAny()) + } +} #endif diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index 58c0faa01f..1f6d482a26 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -7,18 +7,20 @@ #if os(iOS) import XCTest import UIKit +@_spi(Internal) @testable import DatadogSessionReplay -class SessionReplayOverrideTests: XCTestCase { +class SessionReplayOverridesTests: XCTestCase { + // MARK: Setting overrides func testWhenNoOverrideIsSet_itDefaultsToNil() { // Given let view = UIView() // Then - XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hide) + XCTAssertNil(view.dd.sessionReplayOverrides.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.hide) } func testWithOverrides() { @@ -26,37 +28,164 @@ class SessionReplayOverrideTests: XCTestCase { let view = UIView() // When - view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs - view.dd.sessionReplayOverride.imagePrivacy = .maskAll - view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hide = true + view.dd.sessionReplayOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayOverrides.touchPrivacy = .hide + view.dd.sessionReplayOverrides.hide = true // Then - XCTAssertEqual(view.dd.sessionReplayOverride.textAndInputPrivacy, .maskAllInputs) - XCTAssertEqual(view.dd.sessionReplayOverride.imagePrivacy, .maskAll) - XCTAssertEqual(view.dd.sessionReplayOverride.touchPrivacy, .hide) - XCTAssertEqual(view.dd.sessionReplayOverride.hide, true) + XCTAssertEqual(view.dd.sessionReplayOverrides.textAndInputPrivacy, .maskAllInputs) + XCTAssertEqual(view.dd.sessionReplayOverrides.imagePrivacy, .maskAll) + XCTAssertEqual(view.dd.sessionReplayOverrides.touchPrivacy, .hide) + XCTAssertEqual(view.dd.sessionReplayOverrides.hide, true) } func testRemovingOverrides() { // Given let view = UIView() - view.dd.sessionReplayOverride.textAndInputPrivacy = .maskAllInputs - view.dd.sessionReplayOverride.imagePrivacy = .maskAll - view.dd.sessionReplayOverride.touchPrivacy = .hide - view.dd.sessionReplayOverride.hide = true + view.dd.sessionReplayOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayOverrides.touchPrivacy = .hide + view.dd.sessionReplayOverrides.hide = true // When - view.dd.sessionReplayOverride.textAndInputPrivacy = nil - view.dd.sessionReplayOverride.imagePrivacy = nil - view.dd.sessionReplayOverride.touchPrivacy = nil - view.dd.sessionReplayOverride.hide = nil + view.dd.sessionReplayOverrides.textAndInputPrivacy = nil + view.dd.sessionReplayOverrides.imagePrivacy = nil + view.dd.sessionReplayOverrides.touchPrivacy = nil + view.dd.sessionReplayOverrides.hide = nil // Then - XCTAssertNil(view.dd.sessionReplayOverride.textAndInputPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.imagePrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverride.hide) + XCTAssertNil(view.dd.sessionReplayOverrides.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayOverrides.hide) + } + + // MARK: Privacy overrides taking precedence over global settings + func testTextOverrideTakesPrecedenceOverGlobalTextPrivacy() { + // Given + let textAndInputOverride: TextAndInputPrivacyLevel = .mockRandom() + let viewAttributes: ViewAttributes = .mockWith(overrides: .mockWith(textAndInputPrivacy: textAndInputOverride)) + let globalTextAndInputPrivacy: TextAndInputPrivacyLevel = .mockRandom() + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(textAndInputPrivacy: globalTextAndInputPrivacy)) + + // When + let resolvedTextPrivacy = viewAttributes.resolveTextAndInputPrivacyLevel(in: context) + + // Then + XCTAssertEqual(resolvedTextPrivacy, textAndInputOverride) + } + + func testTextGlobalPrivacyIsUsedWhenNoTextOverrideIsSet() { + // Given + let viewAttributes: ViewAttributes = .mockAny() + let globalTextAndInputPrivacy: TextAndInputPrivacyLevel = .mockRandom() + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(textAndInputPrivacy: globalTextAndInputPrivacy)) + + // When + let resolvedPrivacy = viewAttributes.resolveTextAndInputPrivacyLevel(in: context) + + // Then + XCTAssertEqual(resolvedPrivacy, globalTextAndInputPrivacy) + } + + func testImageOverrideTakesPrecedenceOverGlobalImagePrivacy() { + // Given + let imageOverride: ImagePrivacyLevel = .mockRandom() + let viewAttributes: ViewAttributes = .mockWith(overrides: .mockWith(imagePrivacy: imageOverride)) + let globalImagePrivacy: ImagePrivacyLevel = .mockRandom() + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(imagePrivacy: globalImagePrivacy)) + + // When + let resolvedImagePrivacy = viewAttributes.resolveImagePrivacyLevel(in: context) + + // Then + XCTAssertEqual(resolvedImagePrivacy, imageOverride) + } + + func testImageGlobalPrivacyIsUsedWhenNoImageOverrideIsSet() { + // Given + let viewAttributes: ViewAttributes = .mockAny() + let globalImagePrivacy: ImagePrivacyLevel = .mockRandom() + let context = ViewTreeRecordingContext.mockWith(recorder: .mockWith(imagePrivacy: globalImagePrivacy)) + + // When + let resolvedImagePrivacy = viewAttributes.resolveImagePrivacyLevel(in: context) + + // Then + XCTAssertEqual(resolvedImagePrivacy, globalImagePrivacy) + } + + func testMergeParentAndChildOverrides() { + // Given + let overrides: Overrides = .mockRandom() + + let childOverrides: Overrides = .mockAny() + childOverrides.textAndInputPrivacy = overrides.textAndInputPrivacy + // We set the `hide` override on the child because in the merge process, + // the child's override takes precedence. If the parent's `hide` is `false`, + // the final merged value will end up as `nil`, which makes the test fail. + childOverrides.hide = overrides.hide + + let parentOverrides: Overrides = .mockAny() + parentOverrides.imagePrivacy = overrides.imagePrivacy + parentOverrides.touchPrivacy = overrides.touchPrivacy + + // When + let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + + // Then + XCTAssertEqual(merged.textAndInputPrivacy, overrides.textAndInputPrivacy) + XCTAssertEqual(merged.imagePrivacy, overrides.imagePrivacy) + XCTAssertEqual(merged.touchPrivacy, overrides.touchPrivacy) + XCTAssertEqual(merged.touchPrivacy, overrides.touchPrivacy) + } + + func testMergeWithNilParentOverrides() { + // Given + let childOverrides: Overrides = .mockRandom() + let parentOverrides: Overrides = .mockAny() + + // When + let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + + // Then + XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) + XCTAssertEqual(merged.imagePrivacy, childOverrides.imagePrivacy) + XCTAssertEqual(merged.touchPrivacy, childOverrides.touchPrivacy) + XCTAssertEqual(merged.hide, childOverrides.hide) + } + + func testMergeWithNilChildOverrides() { + // Given + let childOverrides: Overrides = .mockAny() + let parentOverrides: Overrides = .mockRandom() + + // When + let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + + // Then + XCTAssertEqual(merged.textAndInputPrivacy, parentOverrides.textAndInputPrivacy) + XCTAssertEqual(merged.imagePrivacy, parentOverrides.imagePrivacy) + XCTAssertEqual(merged.touchPrivacy, parentOverrides.touchPrivacy) + XCTAssertEqual(merged.hide, parentOverrides.hide) + } + + func testMergeWhenChildHideOverrideIsNotNilAndParentHideOverrideIsTrue() { + // Given + let childOverrides: Overrides = .mockRandom() + childOverrides.hide = false + let parentOverrides: Overrides = .mockRandom() + parentOverrides.hide = true + + // When + let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + + // Then + XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) + XCTAssertEqual(merged.imagePrivacy, childOverrides.imagePrivacy) + XCTAssertEqual(merged.touchPrivacy, childOverrides.touchPrivacy) + XCTAssertEqual(merged.hide, true) } } #endif From 8e237288a39bd47902292407922b914facf5f8a4 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Mon, 7 Oct 2024 10:28:12 +0200 Subject: [PATCH 31/43] RUM-6284 Rename SessionReplayOverrides to SessionReplayPrivacyOverrides --- Datadog/Datadog.xcodeproj/project.pbxproj | 11 ++++---- .../ObjcAPITests/DDSessionReplay+apiTests.m | 4 +-- .../ViewAttributes+Copy.swift | 2 +- .../ViewTreeSnapshot/ViewTreeRecorder.swift | 6 ++--- .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 4 +-- .../Sources/SessionReplayOverrides+objc.swift | 12 ++++----- ...ft => SessionReplayPrivacyOverrides.swift} | 16 ++++++------ .../Tests/DDSessionReplayOverridesTests.swift | 8 +++--- .../Tests/Mocks/RecorderMocks.swift | 12 ++++----- .../UIImageViewRecorderTests.swift | 2 +- .../ViewTreeSnapshotTests.swift | 4 +-- .../Tests/SessionReplayOverrideTests.swift | 26 +++++++++---------- 12 files changed, 53 insertions(+), 54 deletions(-) rename DatadogSessionReplay/Sources/{PrivacyLevel+SessionReplay.swift => SessionReplayPrivacyOverrides.swift} (88%) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 6968b6aba0..474e56639b 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -682,6 +682,7 @@ 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */; }; 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; 962C41A72CA431370050B747 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; + 962C41A72CA431370050B747 /* SessionReplayPrivacyOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */; }; 962C41A82CA431AA0050B747 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; 969B3B212C33F80500D62400 /* UIActivityIndicatorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */; }; 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; @@ -2737,7 +2738,7 @@ 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 /* ViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifier.swift; sourceTree = ""; }; - 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrivacyLevel+SessionReplay.swift"; sourceTree = ""; }; + 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionReplayPrivacyOverrides.swift; sourceTree = ""; }; 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorRecorder.swift; sourceTree = ""; }; 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorRecorderTests.swift; sourceTree = ""; }; 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorder.swift; sourceTree = ""; }; @@ -3569,7 +3570,7 @@ children = ( 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */, 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, - 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, + 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */, A795069B2B974C8100AC4814 /* SessionReplay+objc.swift */, 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */, 61054E3B2A6EE10A00AAA894 /* Feature */, @@ -3639,11 +3640,9 @@ 61054E132A6EE10A00AAA894 /* Utilities */ = { isa = PBXGroup; children = ( - 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */, 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */, D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */, - 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */, - 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */, + 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */, 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */, 61054E172A6EE10A00AAA894 /* SystemColors.swift */, 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */, @@ -8390,7 +8389,7 @@ 61054EA12A6EE10B00AAA894 /* MainThreadScheduler.swift in Sources */, 61054E7C2A6EE10A00AAA894 /* UINavigationBarRecorder.swift in Sources */, 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */, - 962C41A72CA431370050B747 /* PrivacyLevel+SessionReplay.swift in Sources */, + 962C41A72CA431370050B747 /* SessionReplayPrivacyOverrides.swift in Sources */, 61054E772A6EE10A00AAA894 /* ViewTreeRecorder.swift in Sources */, 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */, 61054E872A6EE10A00AAA894 /* ViewAttributes+Copy.swift in Sources */, diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index dbf618038d..0b6eb906ab 100644 --- a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m @@ -41,7 +41,7 @@ - (void)testStartAndStopRecording { - (void)testSettingAndGettingOverrides { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayOverrides *override = [[DDSessionReplayOverrides alloc] init]; + DDSessionReplayPrivacyOverrides *override = [[DDSessionReplayPrivacyOverrides alloc] init]; // When view.ddSessionReplayOverrides = override; @@ -60,7 +60,7 @@ - (void)testSettingAndGettingOverrides { - (void)testClearingOverride { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayOverrides *overrides = [[DDSessionReplayOverrides alloc] init]; + DDSessionReplayPrivacyOverrides *overrides = [[DDSessionReplayPrivacyOverrides alloc] init]; // Set initial values view.ddSessionReplayOverrides = overrides; diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift index c02769c969..7baba908d7 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift @@ -25,7 +25,7 @@ extension ViewAttributes { var alpha: CGFloat var isHidden: Bool var intrinsicContentSize: CGSize - var overrides: Overrides + var overrides: PrivacyOverrides fileprivate init(original: ViewAttributes) { self.frame = original.frame diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift index d075f1a05f..86140d33a5 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -27,7 +27,7 @@ internal struct ViewTreeRecorder { nodes: inout [Node], view: UIView, context: ViewTreeRecordingContext, - overrides: Overrides + overrides: PrivacyOverrides ) { var context = context if let viewController = view.next as? UIViewController { @@ -46,7 +46,7 @@ internal struct ViewTreeRecorder { switch semantics.subtreeStrategy { case .record: for subview in view.subviews { - let subviewOverrides = SessionReplayOverrides.merge(subview.dd.sessionReplayOverrides, with: overrides) + let subviewOverrides = SessionReplayPrivacyOverrides.merge(subview.dd.sessionReplayOverrides, with: overrides) recordRecursively(nodes: &nodes, view: subview, context: context, overrides: subviewOverrides) } case .ignore: @@ -54,7 +54,7 @@ internal struct ViewTreeRecorder { } } - private func nodeSemantics(for view: UIView, in context: ViewTreeRecordingContext, overrides: Overrides) -> NodeSemantics { + private func nodeSemantics(for view: UIView, in context: ViewTreeRecordingContext, overrides: PrivacyOverrides) -> NodeSemantics { let attributes = ViewAttributes( frameInRootView: view.convert(view.bounds, to: context.coordinateSpace), view: view, diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 4c0a1277dc..17e9410480 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -125,14 +125,14 @@ public struct SessionReplayViewAttributes: Equatable { var isTranslucent: Bool { !isVisible || alpha < 1 || backgroundColor?.alpha ?? 0 < 1 } /// If the view has privacy overrides, which take precedence over global masking privacy levels. - var overrides: Overrides + var overrides: PrivacyOverrides } // 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, overrides: SessionReplayOverrides) { + init(frameInRootView: CGRect, view: UIView, overrides: SessionReplayPrivacyOverrides) { self.frame = frameInRootView self.backgroundColor = view.backgroundColor?.cgColor.safeCast self.layerBorderColor = view.layer.borderColor?.safeCast diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift index 68ae38a26a..9c004688bf 100644 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift @@ -14,12 +14,12 @@ private var associatedSROverrideKey: UInt8 = 0 /// Objective-C accessible extension for UIView @objc public extension UIView { - @objc var ddSessionReplayOverrides: DDSessionReplayOverrides { + @objc var ddSessionReplayOverrides: DDSessionReplayPrivacyOverrides { get { - if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayOverrides { + if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayPrivacyOverrides { return override } else { - let override = DDSessionReplayOverrides() + let override = DDSessionReplayPrivacyOverrides() objc_setAssociatedObject(self, &associatedSROverrideKey, override, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) return override } @@ -32,13 +32,13 @@ public extension UIView { /// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. @objc -public final class DDSessionReplayOverrides: NSObject { +public final class DDSessionReplayPrivacyOverrides: NSObject { /// Internal Swift equivalent of the Session Replay Override, tied to the view. - internal var _swift: SessionReplayOverrides + internal var _swift: SessionReplayPrivacyOverrides @objc override public init() { - _swift = SessionReplayOverrides(UIView()) + _swift = SessionReplayPrivacyOverrides(UIView()) super.init() } diff --git a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift similarity index 88% rename from DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift rename to DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift index 0e819a664b..e3e8f94b20 100644 --- a/DatadogSessionReplay/Sources/PrivacyLevel+SessionReplay.swift +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift @@ -14,8 +14,8 @@ import DatadogInternal extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. /// Usage: `myView.dd.sessionReplayOverrides.textAndInputPrivacy = .maskNone`. - public var sessionReplayOverrides: SessionReplayOverrides { - return SessionReplayOverrides(self.type) + public var sessionReplayOverrides: SessionReplayPrivacyOverrides { + return SessionReplayPrivacyOverrides(self.type) } } @@ -29,7 +29,7 @@ private var associatedHiddenPrivacyKey: UInt8 = 6 // MARK: - SessionReplayOverrides /// `UIView` extension to manage the Session Replay privacy override settings. -public final class SessionReplayOverrides { +public final class SessionReplayPrivacyOverrides { private let view: UIView public init(_ view: UIView) { @@ -77,8 +77,8 @@ public final class SessionReplayOverrides { } } -extension Overrides: Equatable { - public static func == (lhs: SessionReplayOverrides, rhs: SessionReplayOverrides) -> Bool { +extension PrivacyOverrides: Equatable { + public static func == (lhs: SessionReplayPrivacyOverrides, rhs: SessionReplayPrivacyOverrides) -> Bool { return lhs.view === rhs.view && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy && lhs.imagePrivacy == rhs.imagePrivacy @@ -87,10 +87,10 @@ extension Overrides: Equatable { } } -extension Overrides { +extension PrivacyOverrides { /// Merges child and parent overrides, giving precedence to the child’s overrides, if set. /// If the child has no overrides set, it inherits its parent’s overrides. - internal static func merge(_ child: Overrides, with parent: Overrides) -> Overrides { + internal static func merge(_ child: PrivacyOverrides, with parent: PrivacyOverrides) -> PrivacyOverrides { let merged = child // Apply child overrides if present @@ -109,5 +109,5 @@ extension Overrides { } // This alias enables us to have a more unique name exposed through public-internal access level -internal typealias Overrides = SessionReplayOverrides +internal typealias PrivacyOverrides = SessionReplayPrivacyOverrides #endif diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index bd51367462..d381ec6f0a 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -49,7 +49,7 @@ class DDSessionReplayOverrideTests: XCTestCase { } func testHiddenPrivacyLevelsOverrideInterop() { - let override = DDSessionReplayOverrides() + let override = DDSessionReplayPrivacyOverrides() // When setting hiddenPrivacy via Swift override._swift.hide = true @@ -75,7 +75,7 @@ class DDSessionReplayOverrideTests: XCTestCase { // MARK: Setting Overrides func testSettingAndRemovingPrivacyOverridesObjc() { // Given - let override = DDSessionReplayOverrides() + let override = DDSessionReplayPrivacyOverrides() let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! @@ -109,7 +109,7 @@ class DDSessionReplayOverrideTests: XCTestCase { func testSettingAndGettingOverridesFromObjC() { // Given let view = UIView() - let override = DDSessionReplayOverrides() + let override = DDSessionReplayPrivacyOverrides() // When view.ddSessionReplayOverrides = override @@ -128,7 +128,7 @@ class DDSessionReplayOverrideTests: XCTestCase { func testClearingOverridesFromObjC() { // Given let view = UIView() - let override = DDSessionReplayOverrides() + let override = DDSessionReplayPrivacyOverrides() // Set initial values view.ddSessionReplayOverrides = override diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index a3c5d78f6b..e067e18135 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -79,7 +79,7 @@ extension ViewAttributes: AnyMockable, RandomMockable { alpha: CGFloat = .mockAny(), isHidden: Bool = .mockAny(), intrinsicContentSize: CGSize = .mockAny(), - overrides: Overrides = .mockAny() + overrides: PrivacyOverrides = .mockAny() ) -> ViewAttributes { return .init( frame: frame, @@ -582,12 +582,12 @@ internal extension Optional where Wrapped == NodeSemantics { } } -extension Overrides: AnyMockable, RandomMockable { - public static func mockAny() -> Overrides { +extension PrivacyOverrides: AnyMockable, RandomMockable { + public static func mockAny() -> PrivacyOverrides { return mockWith() } - public static func mockRandom() -> Overrides { + public static func mockRandom() -> PrivacyOverrides { return mockWith( textAndInputPrivacy: .mockRandom(), imagePrivacy: .mockRandom(), @@ -601,8 +601,8 @@ extension Overrides: AnyMockable, RandomMockable { imagePrivacy: ImagePrivacyLevel? = nil, touchPrivacy: TouchPrivacyLevel? = nil, hide: Bool? = nil - ) -> Overrides { - let override = Overrides(UIView.mockRandom()) + ) -> PrivacyOverrides { + let override = PrivacyOverrides(UIView.mockRandom()) override.textAndInputPrivacy = textAndInputPrivacy override.imagePrivacy = imagePrivacy override.touchPrivacy = touchPrivacy diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift index 565f7e4595..411562ac6d 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift @@ -156,7 +156,7 @@ class UIImageViewRecorderTests: XCTestCase { imageView.image = UIImage() let overrideImagePrivacy: ImagePrivacyLevel = .maskAll - let overrides: Overrides = .mockWith(imagePrivacy: overrideImagePrivacy) + let overrides: PrivacyOverrides = .mockWith(imagePrivacy: overrideImagePrivacy) viewAttributes = .mockWith(overrides: overrides) // When diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index 56754a8fae..b0fe13cb30 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -161,7 +161,7 @@ class ViewAttributesTests: XCTestCase { let color: CGColor = .mockRandom() let float: CGFloat = .mockRandom() let boolean: Bool = .mockRandom() - let overrides: Overrides = .mockRandom() + let overrides: PrivacyOverrides = .mockRandom() let attributes = ViewAttributes(frameInRootView: view.frame, view: view, overrides: overrides).copy { $0.frame = rect $0.backgroundColor = color @@ -240,7 +240,7 @@ class ViewAttributesTests: XCTestCase { let childView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) - let parentOverrides: Overrides = .mockRandom() + let parentOverrides: PrivacyOverrides = .mockRandom() parentView.dd.sessionReplayOverrides.textAndInputPrivacy = parentOverrides.textAndInputPrivacy parentView.dd.sessionReplayOverrides.imagePrivacy = parentOverrides.imagePrivacy parentView.dd.sessionReplayOverrides.touchPrivacy = parentOverrides.touchPrivacy diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index 1f6d482a26..029c00cfa3 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -118,21 +118,21 @@ class SessionReplayOverridesTests: XCTestCase { func testMergeParentAndChildOverrides() { // Given - let overrides: Overrides = .mockRandom() + let overrides: PrivacyOverrides = .mockRandom() - let childOverrides: Overrides = .mockAny() + let childOverrides: PrivacyOverrides = .mockAny() childOverrides.textAndInputPrivacy = overrides.textAndInputPrivacy // We set the `hide` override on the child because in the merge process, // the child's override takes precedence. If the parent's `hide` is `false`, // the final merged value will end up as `nil`, which makes the test fail. childOverrides.hide = overrides.hide - let parentOverrides: Overrides = .mockAny() + let parentOverrides: PrivacyOverrides = .mockAny() parentOverrides.imagePrivacy = overrides.imagePrivacy parentOverrides.touchPrivacy = overrides.touchPrivacy // When - let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) // Then XCTAssertEqual(merged.textAndInputPrivacy, overrides.textAndInputPrivacy) @@ -143,11 +143,11 @@ class SessionReplayOverridesTests: XCTestCase { func testMergeWithNilParentOverrides() { // Given - let childOverrides: Overrides = .mockRandom() - let parentOverrides: Overrides = .mockAny() + let childOverrides: PrivacyOverrides = .mockRandom() + let parentOverrides: PrivacyOverrides = .mockAny() // When - let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) // Then XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) @@ -158,11 +158,11 @@ class SessionReplayOverridesTests: XCTestCase { func testMergeWithNilChildOverrides() { // Given - let childOverrides: Overrides = .mockAny() - let parentOverrides: Overrides = .mockRandom() + let childOverrides: PrivacyOverrides = .mockAny() + let parentOverrides: PrivacyOverrides = .mockRandom() // When - let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) // Then XCTAssertEqual(merged.textAndInputPrivacy, parentOverrides.textAndInputPrivacy) @@ -173,13 +173,13 @@ class SessionReplayOverridesTests: XCTestCase { func testMergeWhenChildHideOverrideIsNotNilAndParentHideOverrideIsTrue() { // Given - let childOverrides: Overrides = .mockRandom() + let childOverrides: PrivacyOverrides = .mockRandom() childOverrides.hide = false - let parentOverrides: Overrides = .mockRandom() + let parentOverrides: PrivacyOverrides = .mockRandom() parentOverrides.hide = true // When - let merged = SessionReplayOverrides.merge(childOverrides, with: parentOverrides) + let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) // Then XCTAssertEqual(merged.textAndInputPrivacy, childOverrides.textAndInputPrivacy) From af9e61b1495920c0bbc1652edc45e7c16b61b1fd Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Tue, 22 Oct 2024 11:12:38 +0200 Subject: [PATCH 32/43] Privacy Overrides Objective-C Interface --- Datadog/Datadog.xcodeproj/project.pbxproj | 22 +- .../ObjcAPITests/DDSessionReplay+apiTests.m | 44 ++- .../ViewTreeSnapshot/ViewTreeRecorder.swift | 4 +- .../Sources/SessionReplayOverrides+objc.swift | 158 ----------- .../SessionReplayPrivacyOverrides+objc.swift | 93 +++++++ .../SessionReplayPrivacyOverrides.swift | 6 +- ...w+SessionReplayPrivacyOverrides+objc.swift | 69 +++++ .../Tests/DDSessionReplayOverridesTests.swift | 261 +++++++++++------- .../Tests/DDSessionReplayTests.swift | 1 - .../Mocks/PrivacyOverridesMock+objc.swift | 53 ++++ .../ViewTreeRecorderTests.swift | 18 +- .../ViewTreeSnapshotTests.swift | 12 +- .../Tests/SessionReplayOverrideTests.swift | 51 ++-- 13 files changed, 453 insertions(+), 339 deletions(-) delete mode 100644 DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift create mode 100644 DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides+objc.swift create mode 100644 DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift create mode 100644 DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 474e56639b..647122b78a 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -680,20 +680,21 @@ 61FDBA15269722B4001D9D43 /* CrashReportMinifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA14269722B4001D9D43 /* CrashReportMinifierTests.swift */; }; 61FDBA1726974CA9001D9D43 /* DDCrashReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1626974CA9001D9D43 /* DDCrashReportBuilderTests.swift */; }; 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282724B8A31E000B3D9B /* RUMEventMatcher.swift */; }; - 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; - 962C41A72CA431370050B747 /* PrivacyLevel+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* PrivacyLevel+SessionReplay.swift */; }; 962C41A72CA431370050B747 /* SessionReplayPrivacyOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */; }; 962C41A82CA431AA0050B747 /* DDSessionReplayOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */; }; + 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */; }; 969B3B212C33F80500D62400 /* UIActivityIndicatorRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B202C33F80500D62400 /* UIActivityIndicatorRecorder.swift */; }; 969B3B232C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B3B222C33F81E00D62400 /* UIActivityIndicatorRecorderTests.swift */; }; 96E414142C2AF56F005A6119 /* UIProgressViewRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */; }; 96E414162C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E414152C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift */; }; + 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; + 96F25A822CC7EA4400459567 /* SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */; }; + 96F25A832CC7EA4400459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */; }; + 96F25A852CC7EB3700459567 /* PrivacyOverridesMock+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A842CC7EB3700459567 /* PrivacyOverridesMock+objc.swift */; }; 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 96F69D6E2CBE94F500A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; 96F69D6F2CBE94F600A6178B /* MockFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71265852B17980C007D63CE /* MockFeature.swift */; }; - 96E863722C9C547B0023BF78 /* SessionReplayOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */; }; - 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */; }; 9E55407C25812D1C00F6E3AD /* RUM+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E55407B25812D1C00F6E3AD /* RUM+objc.swift */; }; 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E5B6D2E270C84B4002499B8 /* RUMMonitorE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B6D2D270C84B4002499B8 /* RUMMonitorE2ETests.swift */; }; @@ -2744,8 +2745,10 @@ 96E414132C2AF56F005A6119 /* UIProgressViewRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorder.swift; sourceTree = ""; }; 96E414152C2AF5C1005A6119 /* UIProgressViewRecorderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIProgressViewRecorderTests.swift; sourceTree = ""; }; 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionReplayOverrideTests.swift; sourceTree = ""; }; - 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionReplayOverrides+objc.swift"; sourceTree = ""; }; 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSessionReplayOverridesTests.swift; sourceTree = ""; }; + 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SessionReplayPrivacyOverrides+objc.swift"; sourceTree = ""; }; + 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+SessionReplayPrivacyOverrides+objc.swift"; sourceTree = ""; }; + 96F25A842CC7EB3700459567 /* PrivacyOverridesMock+objc.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PrivacyOverridesMock+objc.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 = ""; }; @@ -3572,7 +3575,8 @@ 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */, A795069B2B974C8100AC4814 /* SessionReplay+objc.swift */, - 96E863732C9C64180023BF78 /* SessionReplayOverrides+objc.swift */, + 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */, + 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */, 61054E3B2A6EE10A00AAA894 /* Feature */, 61054E482A6EE10A00AAA894 /* Processor */, 61054E0D2A6EE10A00AAA894 /* Recorder */, @@ -3999,6 +4003,7 @@ 61054F7D2A6EE1BA00AAA894 /* Mocks */ = { isa = PBXGroup; children = ( + 96F25A842CC7EB3700459567 /* PrivacyOverridesMock+objc.swift */, 3C33E4062BEE35A7003B2988 /* RUMContextMocks.swift */, 61054F7E2A6EE1BA00AAA894 /* UIKitMocks.swift */, 61054F7F2A6EE1BA00AAA894 /* CoreGraphicsMocks.swift */, @@ -4344,7 +4349,6 @@ A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */, 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */, 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, - D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); @@ -8376,7 +8380,6 @@ 61054E612A6EE10A00AAA894 /* SRCompression.swift in Sources */, 61054E8F2A6EE10A00AAA894 /* SegmentRequestBuilder.swift in Sources */, 61054E8B2A6EE10A00AAA894 /* SessionReplayFeature.swift in Sources */, - 96E863742C9C64180023BF78 /* SessionReplayOverrides+objc.swift in Sources */, 61054E992A6EE10A00AAA894 /* WireframesBuilder.swift in Sources */, 61054E892A6EE10A00AAA894 /* NodeIDGenerator.swift in Sources */, 61054E962A6EE10A00AAA894 /* Diff+SRWireframes.swift in Sources */, @@ -8394,6 +8397,7 @@ 61054E9E2A6EE10B00AAA894 /* Queue.swift in Sources */, 61054E872A6EE10A00AAA894 /* ViewAttributes+Copy.swift in Sources */, 61054E6A2A6EE10A00AAA894 /* UIView+SessionReplay.swift in Sources */, + 96F25A832CC7EA4400459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift in Sources */, 61054E7D2A6EE10A00AAA894 /* UITextFieldRecorder.swift in Sources */, 61054E832A6EE10A00AAA894 /* UISwitchRecorder.swift in Sources */, 61054E9A2A6EE10A00AAA894 /* NodesFlattener.swift in Sources */, @@ -8428,6 +8432,7 @@ D22442C52CA301DA002E71E4 /* UIColor+SessionReplay.swift in Sources */, 61054E8E2A6EE10A00AAA894 /* SRContextPublisher.swift in Sources */, 61054E732A6EE10A00AAA894 /* WindowTouchSnapshotProducer.swift in Sources */, + 96F25A822CC7EA4400459567 /* SessionReplayPrivacyOverrides+objc.swift in Sources */, A70ADCD22B583B1300321BC9 /* UIImageResource.swift in Sources */, 61054E792A6EE10A00AAA894 /* UITextViewRecorder.swift in Sources */, 61054E9B2A6EE10B00AAA894 /* CGRectExtensions.swift in Sources */, @@ -8468,6 +8473,7 @@ 61054FC22A6EE1BA00AAA894 /* ViewTreeSnapshotTests.swift in Sources */, D2BCB2A32B7B9683005C2AAB /* WKWebViewRecorderTests.swift in Sources */, 61054FC62A6EE1BA00AAA894 /* CoreGraphicsMocks.swift in Sources */, + 96F25A852CC7EB3700459567 /* PrivacyOverridesMock+objc.swift in Sources */, 61054FCA2A6EE1BA00AAA894 /* TestScheduler.swift in Sources */, 962C41A92CB00FD60050B747 /* DDSessionReplayTests.swift in Sources */, 61054FBD2A6EE1BA00AAA894 /* UIViewRecorderTests.swift in Sources */, diff --git a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index 0b6eb906ab..3fcc49b46b 100644 --- a/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m +++ b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m @@ -41,44 +41,40 @@ - (void)testStartAndStopRecording { - (void)testSettingAndGettingOverrides { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayPrivacyOverrides *override = [[DDSessionReplayPrivacyOverrides alloc] init]; // When - view.ddSessionReplayOverrides = override; - view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; - view.ddSessionReplayOverrides.hide = @YES; + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayPrivacyOverrides.hide = @YES; // Then - XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideMaskAll); - XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, DDImagePrivacyLevelOverrideMaskAll); - XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideHide); - XCTAssertTrue(view.ddSessionReplayOverrides.hide.boolValue); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, DDImagePrivacyLevelOverrideMaskAll); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideHide); + XCTAssertTrue(view.ddSessionReplayPrivacyOverrides.hide.boolValue); } - (void)testClearingOverride { // Given UIView *view = [[UIView alloc] init]; - DDSessionReplayPrivacyOverrides *overrides = [[DDSessionReplayPrivacyOverrides alloc] init]; // Set initial values - view.ddSessionReplayOverrides = overrides; - view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; - view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; - view.ddSessionReplayOverrides.hide = @YES; + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayPrivacyOverrides.hide = @YES; // When - view.ddSessionReplayOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; - view.ddSessionReplayOverrides.imagePrivacy = DDImagePrivacyLevelOverrideNone; - view.ddSessionReplayOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideNone; - view.ddSessionReplayOverrides.hide = nil; + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.hide = nil; // Then - XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); - XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, DDImagePrivacyLevelOverrideNone); - XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideNone); - XCTAssertNil(view.ddSessionReplayOverrides.hide); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, DDImagePrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideNone); + XCTAssertNil(view.ddSessionReplayPrivacyOverrides.hide); } @end diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift index 86140d33a5..a063196cee 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorder.swift @@ -17,7 +17,7 @@ internal struct ViewTreeRecorder { /// Creates `Nodes` for given view and its subtree hierarchy. func record(_ anyView: UIView, in context: ViewTreeRecordingContext) -> [Node] { var nodes: [Node] = [] - recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd.sessionReplayOverrides) + recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd.sessionReplayPrivacyOverrides) return nodes } @@ -46,7 +46,7 @@ internal struct ViewTreeRecorder { switch semantics.subtreeStrategy { case .record: for subview in view.subviews { - let subviewOverrides = SessionReplayPrivacyOverrides.merge(subview.dd.sessionReplayOverrides, with: overrides) + let subviewOverrides = SessionReplayPrivacyOverrides.merge(subview.dd.sessionReplayPrivacyOverrides, with: overrides) recordRecursively(nodes: &nodes, view: subview, context: context, overrides: subviewOverrides) } case .ignore: diff --git a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift deleted file mode 100644 index 9c004688bf..0000000000 --- a/DatadogSessionReplay/Sources/SessionReplayOverrides+objc.swift +++ /dev/null @@ -1,158 +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. - */ - -#if os(iOS) -import Foundation -import DatadogInternal -import UIKit - -private var associatedSROverrideKey: UInt8 = 0 - -/// Objective-C accessible extension for UIView -@objc -public extension UIView { - @objc var ddSessionReplayOverrides: DDSessionReplayPrivacyOverrides { - get { - if let override = objc_getAssociatedObject(self, &associatedSROverrideKey) as? DDSessionReplayPrivacyOverrides { - return override - } else { - let override = DDSessionReplayPrivacyOverrides() - objc_setAssociatedObject(self, &associatedSROverrideKey, override, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return override - } - } - set { - objc_setAssociatedObject(self, &associatedSROverrideKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } -} - -/// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. -@objc -public final class DDSessionReplayPrivacyOverrides: NSObject { - /// Internal Swift equivalent of the Session Replay Override, tied to the view. - internal var _swift: SessionReplayPrivacyOverrides - - @objc - override public init() { - _swift = SessionReplayPrivacyOverrides(UIView()) - super.init() - } - - /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). - @objc public var textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride { - get { return .init(_swift.textAndInputPrivacy) } - set { _swift.textAndInputPrivacy = newValue._swift } - } - - /// Image privacy override (e.g., mask or unmask specific images). - @objc public var imagePrivacy: DDImagePrivacyLevelOverride { - get { return .init(_swift.imagePrivacy) } - set { _swift.imagePrivacy = newValue._swift } - } - - /// Touch privacy override (e.g., hide or show touch interactions on specific views). - @objc public var touchPrivacy: DDTouchPrivacyLevelOverride { - get { return .init(_swift.touchPrivacy) } - set { _swift.touchPrivacy = newValue._swift } - } - - /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). - @objc public var hide: NSNumber? { - get { - guard let hide = _swift.hide else { - return nil - } - return NSNumber(value: hide) - } - set { - if let newValue = newValue { - _swift.hide = newValue.boolValue - } else { - _swift.hide = nil - } - } - } -} - -/// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). -@objc -public enum DDTextAndInputPrivacyLevelOverride: Int { - case none // Represents `nil` in Swift - case maskSensitiveInputs - case maskAllInputs - case maskAll - - internal var _swift: TextAndInputPrivacyLevel? { - switch self { - case .none: return nil - case .maskSensitiveInputs: return .maskSensitiveInputs - case .maskAllInputs: return .maskAllInputs - case .maskAll: return .maskAll - } - } - - internal init(_ swift: TextAndInputPrivacyLevel?) { - switch swift { - case .maskSensitiveInputs: self = .maskSensitiveInputs - case .maskAllInputs: self = .maskAllInputs - case .maskAll: self = .maskAll - case nil: self = .none - } - } -} - -/// Image privacy override (e.g., mask or unmask specific images). -@objc -public enum DDImagePrivacyLevelOverride: Int { - case none // Represents `nil` in Swift - case maskNone - case maskNonBundledOnly - case maskAll - - internal var _swift: ImagePrivacyLevel? { - switch self { - case .none: return nil - case .maskNone: return .maskNone - case .maskNonBundledOnly: return .maskNonBundledOnly - case .maskAll: return .maskAll - } - } - - internal init(_ swift: ImagePrivacyLevel?) { - switch swift { - case .maskNone: self = .maskNone - case .maskNonBundledOnly: self = .maskNonBundledOnly - case .maskAll: self = .maskAll - case nil: self = .none - } - } -} - -/// Touch privacy override (e.g., hide or show touch interactions on specific views). -@objc -public enum DDTouchPrivacyLevelOverride: Int { - case none // Represents `nil` in Swift - case show - case hide - - internal var _swift: TouchPrivacyLevel? { - switch self { - case .none: return nil - case .show: return .show - case .hide: return .hide - } - } - - internal init(_ swift: TouchPrivacyLevel?) { - switch swift { - case .show: self = .show - case .hide: self = .hide - case nil: self = .none - } - } -} -#endif diff --git a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides+objc.swift b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides+objc.swift new file mode 100644 index 0000000000..0b742918e8 --- /dev/null +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides+objc.swift @@ -0,0 +1,93 @@ +/* + * 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 UIKit + +// MARK: DDTextAndInputPrivacyLevelOverride +/// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). +@objc(DDTextAndInputPrivacyLevelOverride) +@_spi(objc) +public enum objc_TextAndInputPrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case maskSensitiveInputs + case maskAllInputs + case maskAll + + internal var _swift: TextAndInputPrivacyLevel? { + switch self { + case .none: return nil + case .maskSensitiveInputs: return .maskSensitiveInputs + case .maskAllInputs: return .maskAllInputs + case .maskAll: return .maskAll + } + } + + internal init(_ swift: TextAndInputPrivacyLevel?) { + switch swift { + case .maskSensitiveInputs: self = .maskSensitiveInputs + case .maskAllInputs: self = .maskAllInputs + case .maskAll: self = .maskAll + case nil: self = .none + } + } +} + +// MARK: DDImagePrivacyLevelOverride +/// Image privacy override (e.g., mask or unmask specific images). +@objc(DDImagePrivacyLevelOverride) +@_spi(objc) +public enum objc_ImagePrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case maskNone + case maskNonBundledOnly + case maskAll + + internal var _swift: ImagePrivacyLevel? { + switch self { + case .none: return nil + case .maskNone: return .maskNone + case .maskNonBundledOnly: return .maskNonBundledOnly + case .maskAll: return .maskAll + } + } + + internal init(_ swift: ImagePrivacyLevel?) { + switch swift { + case .maskNone: self = .maskNone + case .maskNonBundledOnly: self = .maskNonBundledOnly + case .maskAll: self = .maskAll + case nil: self = .none + } + } +} + +// MARK: DDTouchPrivacyLevelOverride +/// Touch privacy override (e.g., hide or show touch interactions on specific views). +@objc(DDTouchPrivacyLevelOverride) +@_spi(objc) +public enum objc_TouchPrivacyLevelOverride: Int { + case none // Represents `nil` in Swift + case show + case hide + + internal var _swift: TouchPrivacyLevel? { + switch self { + case .none: return nil + case .show: return .show + case .hide: return .hide + } + } + + internal init(_ swift: TouchPrivacyLevel?) { + switch swift { + case .show: self = .show + case .hide: self = .hide + case nil: self = .none + } + } +} +#endif diff --git a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift index e3e8f94b20..afaeaf7950 100644 --- a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift @@ -14,7 +14,7 @@ import DatadogInternal extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. /// Usage: `myView.dd.sessionReplayOverrides.textAndInputPrivacy = .maskNone`. - public var sessionReplayOverrides: SessionReplayPrivacyOverrides { + public var sessionReplayPrivacyOverrides: SessionReplayPrivacyOverrides { return SessionReplayPrivacyOverrides(self.type) } } @@ -30,7 +30,7 @@ private var associatedHiddenPrivacyKey: UInt8 = 6 /// `UIView` extension to manage the Session Replay privacy override settings. public final class SessionReplayPrivacyOverrides { - private let view: UIView + internal let view: UIView public init(_ view: UIView) { self.view = view @@ -77,6 +77,7 @@ public final class SessionReplayPrivacyOverrides { } } +// MARK: - Equatable extension PrivacyOverrides: Equatable { public static func == (lhs: SessionReplayPrivacyOverrides, rhs: SessionReplayPrivacyOverrides) -> Bool { return lhs.view === rhs.view @@ -87,6 +88,7 @@ extension PrivacyOverrides: Equatable { } } +// MARK: - Merge extension PrivacyOverrides { /// Merges child and parent overrides, giving precedence to the child’s overrides, if set. /// If the child has no overrides set, it inherits its parent’s overrides. diff --git a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift new file mode 100644 index 0000000000..2f57516a92 --- /dev/null +++ b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift @@ -0,0 +1,69 @@ +/* + * 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 UIKit + +private var associatedSROverrideKey: UInt8 = 0 + +// MARK: UIView extension +/// Objective-C accessible extension for UIView +@objc +@_spi(objc) +public extension UIView { + @objc var ddSessionReplayPrivacyOverrides: objc_SessionReplayPrivacyOverrides { + return objc_SessionReplayPrivacyOverrides(view: self) + } +} + +// MARK: DDSessionReplayPrivacyOverrides +/// A wrapper class for Objective-C compatibility, providing overrides for Session Replay privacy settings. +@objc(DDSessionReplayPrivacyOverrides) +@objcMembers +@_spi(objc) +public final class objc_SessionReplayPrivacyOverrides: NSObject { + /// Internal Swift equivalent of the Session Replay Override, tied to the view. + internal var _swift: PrivacyOverrides + + @objc + public init(view: UIView) { + _swift = PrivacyOverrides(view) + super.init() + } + + /// Text and input privacy override (e.g., mask or unmask specific text fields, labels, etc.). + @objc public var textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride { + get { return objc_TextAndInputPrivacyLevelOverride(_swift.textAndInputPrivacy) } + set { _swift.textAndInputPrivacy = newValue._swift } + } + + /// Image privacy override (e.g., mask or unmask specific images). + @objc public var imagePrivacy: objc_ImagePrivacyLevelOverride { + get { return objc_ImagePrivacyLevelOverride(_swift.imagePrivacy) } + set { _swift.imagePrivacy = newValue._swift } + } + + /// Touch privacy override (e.g., hide or show touch interactions on specific views). + @objc public var touchPrivacy: objc_TouchPrivacyLevelOverride { + get { return objc_TouchPrivacyLevelOverride(_swift.touchPrivacy) } + set { _swift.touchPrivacy = newValue._swift } + } + + /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). + @objc public var hide: NSNumber? { + get { + guard let hide = _swift.hide else { + return nil + } + return NSNumber(value: hide) + } + set { + _swift.hide = newValue?.boolValue + } + } +} + +#endif diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index d381ec6f0a..d8b4d9135d 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -9,145 +9,196 @@ import XCTest import TestUtilities import DatadogInternal - +@_spi(objc) @testable import DatadogSessionReplay class DDSessionReplayOverrideTests: XCTestCase { // MARK: Privacy Overrides Interoperability func testTextAndInputPrivacyLevelsOverrideInterop() { - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAll._swift, .maskAll) - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskAllInputs._swift, .maskAllInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride.maskSensitiveInputs._swift, .maskSensitiveInputs) - XCTAssertNil(DDTextAndInputPrivacyLevelOverride.none._swift) - - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskAll), .maskAll) - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskAllInputs), .maskAllInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(.maskSensitiveInputs), .maskSensitiveInputs) - XCTAssertEqual(DDTextAndInputPrivacyLevelOverride(nil), .none) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride.maskAll._swift, .maskAll) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride.maskAllInputs._swift, .maskAllInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride.maskSensitiveInputs._swift, .maskSensitiveInputs) + XCTAssertNil(objc_TextAndInputPrivacyLevelOverride.none._swift) + + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride(.maskAll), .maskAll) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride(.maskAllInputs), .maskAllInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride(.maskSensitiveInputs), .maskSensitiveInputs) + XCTAssertEqual(objc_TextAndInputPrivacyLevelOverride(nil), .none) } func testImagePrivacyLevelsOverrideInterop() { - XCTAssertEqual(DDImagePrivacyLevelOverride.maskAll._swift, .maskAll) - XCTAssertEqual(DDImagePrivacyLevelOverride.maskNonBundledOnly._swift, .maskNonBundledOnly) - XCTAssertEqual(DDImagePrivacyLevelOverride.maskNone._swift, .maskNone) - XCTAssertNil(DDImagePrivacyLevelOverride.none._swift) - - XCTAssertEqual(DDImagePrivacyLevelOverride(.maskAll), .maskAll) - XCTAssertEqual(DDImagePrivacyLevelOverride(.maskNonBundledOnly), .maskNonBundledOnly) - XCTAssertEqual(DDImagePrivacyLevelOverride(.maskNone), .maskNone) - XCTAssertEqual(DDImagePrivacyLevelOverride(nil), .none) + XCTAssertEqual(objc_ImagePrivacyLevelOverride.maskAll._swift, .maskAll) + XCTAssertEqual(objc_ImagePrivacyLevelOverride.maskNonBundledOnly._swift, .maskNonBundledOnly) + XCTAssertEqual(objc_ImagePrivacyLevelOverride.maskNone._swift, .maskNone) + XCTAssertNil(objc_ImagePrivacyLevelOverride.none._swift) + + XCTAssertEqual(objc_ImagePrivacyLevelOverride(.maskAll), .maskAll) + XCTAssertEqual(objc_ImagePrivacyLevelOverride(.maskNonBundledOnly), .maskNonBundledOnly) + XCTAssertEqual(objc_ImagePrivacyLevelOverride(.maskNone), .maskNone) + XCTAssertEqual(objc_ImagePrivacyLevelOverride(nil), .none) } func testTouchPrivacyLevelsOverrideInterop() { - XCTAssertEqual(DDTouchPrivacyLevelOverride.show._swift, .show) - XCTAssertEqual(DDTouchPrivacyLevelOverride.hide._swift, .hide) - XCTAssertNil(DDTouchPrivacyLevelOverride.none._swift) + XCTAssertEqual(objc_TouchPrivacyLevelOverride.show._swift, .show) + XCTAssertEqual(objc_TouchPrivacyLevelOverride.hide._swift, .hide) + XCTAssertNil(objc_TouchPrivacyLevelOverride.none._swift) - XCTAssertEqual(DDTouchPrivacyLevelOverride(.show), .show) - XCTAssertEqual(DDTouchPrivacyLevelOverride(.hide), .hide) - XCTAssertEqual(DDTouchPrivacyLevelOverride(nil), .none) + XCTAssertEqual(objc_TouchPrivacyLevelOverride(.show), .show) + XCTAssertEqual(objc_TouchPrivacyLevelOverride(.hide), .hide) + XCTAssertEqual(objc_TouchPrivacyLevelOverride(nil), .none) } - func testHiddenPrivacyLevelsOverrideInterop() { - let override = DDSessionReplayPrivacyOverrides() + func testHidePrivacyLevelsOverrideInterop() { + // Testing Swift -> Objective-C interaction + let view = UIView() + let objcOverrides = view.ddSessionReplayPrivacyOverrides - // When setting hiddenPrivacy via Swift - override._swift.hide = true - XCTAssertEqual(override.hide, NSNumber(value: true)) + // Set via Swift + view.dd.sessionReplayPrivacyOverrides.hide = true + XCTAssertEqual(objcOverrides.hide, NSNumber(value: true)) - override._swift.hide = false - XCTAssertEqual(override.hide, NSNumber(value: false)) + view.dd.sessionReplayPrivacyOverrides.hide = false + XCTAssertEqual(objcOverrides.hide, NSNumber(value: false)) - override._swift.hide = nil - XCTAssertNil(override.hide) + view.dd.sessionReplayPrivacyOverrides.hide = nil + XCTAssertNil(objcOverrides.hide) - // When setting hiddenPrivacy via Objective-C - override.hide = NSNumber(value: true) - XCTAssertEqual(override._swift.hide, true) + // Set via Objective-C + objcOverrides.hide = NSNumber(value: true) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, true) - override.hide = NSNumber(value: false) - XCTAssertEqual(override._swift.hide, false) + objcOverrides.hide = NSNumber(value: false) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, false) - override.hide = nil - XCTAssertNil(override._swift.hide) + objcOverrides.hide = nil + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) } - // MARK: Setting Overrides - func testSettingAndRemovingPrivacyOverridesObjc() { + // MARK: Setting Privacy Overrides + func testSettingAndClearingObjectOverridesInObjc() { // Given - let override = DDSessionReplayPrivacyOverrides() - let textAndInputPrivacy: DDTextAndInputPrivacyLevelOverride = [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! - let imagePrivacy: DDImagePrivacyLevelOverride = [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! - let touchPrivacy: DDTouchPrivacyLevelOverride = [.show, .hide].randomElement()! - let hide: NSNumber? = [true, false].randomElement().map { NSNumber(value: $0) } ?? nil + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomHidePrivacy() // When - override.textAndInputPrivacy = textAndInputPrivacy - override.imagePrivacy = imagePrivacy - override.touchPrivacy = touchPrivacy - override.hide = hide + let overrides = objc_SessionReplayPrivacyOverrides(view: UIView()) + overrides.textAndInputPrivacy = textAndInputPrivacy + overrides.imagePrivacy = imagePrivacy + overrides.touchPrivacy = touchPrivacy + overrides.hide = hidePrivacy // Then - XCTAssertEqual(override.textAndInputPrivacy, textAndInputPrivacy) - XCTAssertEqual(override.imagePrivacy, imagePrivacy) - XCTAssertEqual(override.touchPrivacy, touchPrivacy) - XCTAssertEqual(override.hide, hide) + XCTAssertEqual(overrides.textAndInputPrivacy, textAndInputPrivacy) + XCTAssertEqual(overrides.imagePrivacy, imagePrivacy) + XCTAssertEqual(overrides.touchPrivacy, touchPrivacy) + XCTAssertEqual(overrides.hide, hidePrivacy) // When - override.textAndInputPrivacy = .none - override.imagePrivacy = .none - override.touchPrivacy = .none - override.hide = false + overrides.textAndInputPrivacy = .none + overrides.imagePrivacy = .none + overrides.touchPrivacy = .none + overrides.hide = false // Then - XCTAssertEqual(override.textAndInputPrivacy, .none) - XCTAssertEqual(override.imagePrivacy, .none) - XCTAssertEqual(override.touchPrivacy, .none) - XCTAssertEqual(override.hide, false) + XCTAssertEqual(overrides.textAndInputPrivacy, .none) + XCTAssertEqual(overrides.imagePrivacy, .none) + XCTAssertEqual(overrides.touchPrivacy, .none) + XCTAssertEqual(overrides.hide, false) } - func testSettingAndGettingOverridesFromObjC() { - // Given - let view = UIView() - let override = DDSessionReplayPrivacyOverrides() - - // When - view.ddSessionReplayOverrides = override - override.textAndInputPrivacy = .maskAll - override.imagePrivacy = .maskAll - override.touchPrivacy = .hide - override.hide = NSNumber(value: true) - - // Then - XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, .maskAll) - XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, .maskAll) - XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, .hide) - XCTAssertEqual(view.ddSessionReplayOverrides.hide?.boolValue, true) - } + func testSettingAndClearingViewOverridesInObjc() { + // Given + let view = UIView() + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomHidePrivacy() - func testClearingOverridesFromObjC() { - // Given - let view = UIView() - let override = DDSessionReplayPrivacyOverrides() - - // Set initial values - view.ddSessionReplayOverrides = override - override.textAndInputPrivacy = .maskAll - override.imagePrivacy = .maskAll - override.touchPrivacy = .hide - override.hide = NSNumber(value: true) - - // When - view.ddSessionReplayOverrides.textAndInputPrivacy = .none - view.ddSessionReplayOverrides.imagePrivacy = .none - view.ddSessionReplayOverrides.touchPrivacy = .none - view.ddSessionReplayOverrides.hide = nil - - // Then - XCTAssertEqual(view.ddSessionReplayOverrides.textAndInputPrivacy, .none) - XCTAssertEqual(view.ddSessionReplayOverrides.imagePrivacy, .none) - XCTAssertEqual(view.ddSessionReplayOverrides.touchPrivacy, .none) - XCTAssertNil(view.ddSessionReplayOverrides.hide) + // When + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy + view.ddSessionReplayPrivacyOverrides.imagePrivacy = imagePrivacy + view.ddSessionReplayPrivacyOverrides.touchPrivacy = touchPrivacy + view.ddSessionReplayPrivacyOverrides.hide = hidePrivacy + + // Then + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, textAndInputPrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, imagePrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, touchPrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.hide, hidePrivacy) + + // When + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = .none + view.ddSessionReplayPrivacyOverrides.imagePrivacy = .none + view.ddSessionReplayPrivacyOverrides.touchPrivacy = .none + view.ddSessionReplayPrivacyOverrides.hide = nil + + // Then + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, .none) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, .none) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, .none) + XCTAssertNil(view.ddSessionReplayPrivacyOverrides.hide) + } + + func testSwiftChangesReflectInObjC() { + // Given + let view = UIView() + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomHidePrivacy() + + // When (set in Swift) + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy._swift + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = imagePrivacy._swift + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = touchPrivacy._swift + view.dd.sessionReplayPrivacyOverrides.hide = hidePrivacy?.boolValue + + // Then (check in ObjC) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, textAndInputPrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, imagePrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, touchPrivacy) + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.hide, hidePrivacy) + } + + func testObjCChangesReflectInSwift() { + // Given + let view = UIView() + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomHidePrivacy() + + // When (set in ObjC) + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy + view.ddSessionReplayPrivacyOverrides.imagePrivacy = imagePrivacy + view.ddSessionReplayPrivacyOverrides.touchPrivacy = touchPrivacy + view.ddSessionReplayPrivacyOverrides.hide = hidePrivacy + + // Then (check in Swift) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy, textAndInputPrivacy._swift) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.imagePrivacy, imagePrivacy._swift) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.touchPrivacy, touchPrivacy._swift) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, hidePrivacy?.boolValue) + } + + func testReleasingOverridesWhenViewIsDeallocated() { + weak var view: UIView? + + autoreleasepool { + let tempView = UIView() + view = tempView + tempView.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = .mockRandom() + tempView.ddSessionReplayPrivacyOverrides.imagePrivacy = .mockRandom() + tempView.ddSessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() + tempView.ddSessionReplayPrivacyOverrides.hide = NSNumber.mockRandomHidePrivacy() } + + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.textAndInputPrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.imagePrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.touchPrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.hide) + } } #endif diff --git a/DatadogSessionReplay/Tests/DDSessionReplayTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayTests.swift index ab9c7c7937..9517fa0cbd 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayTests.swift @@ -5,7 +5,6 @@ */ #if os(iOS) - import XCTest import TestUtilities import DatadogInternal diff --git a/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift new file mode 100644 index 0000000000..807a011ee9 --- /dev/null +++ b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift @@ -0,0 +1,53 @@ +/* + * 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 +@_spi(objc) +@testable import DatadogSessionReplay +import TestUtilities + +extension objc_TextAndInputPrivacyLevelOverride: AnyMockable, RandomMockable { + public static func mockAny() -> Self { + return .maskSensitiveInputs + } + + public static func mockRandom() -> Self { + return [.maskAll, .maskAllInputs, .maskSensitiveInputs].randomElement()! + } +} + +extension objc_ImagePrivacyLevelOverride: AnyMockable, RandomMockable { + public static func mockAny() -> Self { + return .maskNonBundledOnly + } + + public static func mockRandom() -> Self { + return [.maskAll, .maskNonBundledOnly, .maskNone].randomElement()! + } +} + +extension objc_TouchPrivacyLevelOverride: AnyMockable, RandomMockable { + public static func mockAny() -> Self { + return .show + } + + public static func mockRandom() -> Self { + return [.show, .hide].randomElement()! + } +} + +extension NSNumber { + static func mockAnyHidePrivacy() -> NSNumber? { + return NSNumber(value: true) + } + + static func mockRandomHidePrivacy() -> NSNumber? { + return [true, false].randomElement().map { NSNumber(value: $0) } ?? nil + } +} +#endif diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index c4b1c5e090..5dd34b2ad6 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift @@ -303,7 +303,7 @@ class ViewTreeRecorderTests: XCTestCase { let childView = UIView.mock(withFixture: .visible(.someAppearance)) let parentView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) - parentView.dd.sessionReplayOverrides.hide = true + parentView.dd.sessionReplayPrivacyOverrides.hide = true // When let nodes = recorder.record(parentView, in: .mockRandom()) @@ -316,10 +316,10 @@ class ViewTreeRecorderTests: XCTestCase { // Given let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) let childView = UIView.mock(withFixture: .visible(.someAppearance)) - childView.dd.sessionReplayOverrides.hide = true + childView.dd.sessionReplayPrivacyOverrides.hide = true let parentView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) - parentView.dd.sessionReplayOverrides.hide = false + parentView.dd.sessionReplayPrivacyOverrides.hide = false // When let nodes = recorder.record(parentView, in: .mockRandom()) @@ -331,7 +331,7 @@ class ViewTreeRecorderTests: XCTestCase { func testChildViewHideOverrideIsTrueAndParentHideOverrideIsNil() { // Given let childView = UIView.mock(withFixture: .visible(.someAppearance)) - childView.dd.sessionReplayOverrides.hide = true + childView.dd.sessionReplayPrivacyOverrides.hide = true let parentView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) @@ -351,7 +351,7 @@ class ViewTreeRecorderTests: XCTestCase { let viewImagePrivacy: ImagePrivacyLevel = .maskNone let imageView = UIImageView.mock(withFixture: .visible(.someAppearance)) imageView.image = .mockRandom() - imageView.dd.sessionReplayOverrides.imagePrivacy = viewImagePrivacy + imageView.dd.sessionReplayPrivacyOverrides.imagePrivacy = viewImagePrivacy // When let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) @@ -372,10 +372,10 @@ class ViewTreeRecorderTests: XCTestCase { let childImagePrivacy: ImagePrivacyLevel = .maskNone let childView = UIImageView.mock(withFixture: .visible(.someAppearance)) childView.image = .mockRandom() - childView.dd.sessionReplayOverrides.imagePrivacy = childImagePrivacy + childView.dd.sessionReplayPrivacyOverrides.imagePrivacy = childImagePrivacy let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() let parentView = UIView.mock(withFixture: .visible(.someAppearance)) - parentView.dd.sessionReplayOverrides.imagePrivacy = parentImagePrivacy + parentView.dd.sessionReplayPrivacyOverrides.imagePrivacy = parentImagePrivacy parentView.addSubview(childView) // When @@ -396,10 +396,10 @@ class ViewTreeRecorderTests: XCTestCase { let childImagePrivacy: ImagePrivacyLevel = .maskAll let childView = UIImageView.mock(withFixture: .visible(.someAppearance)) childView.image = .mockRandom() - childView.dd.sessionReplayOverrides.imagePrivacy = childImagePrivacy + childView.dd.sessionReplayPrivacyOverrides.imagePrivacy = childImagePrivacy let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() let parentView = UIView.mock(withFixture: .visible(.someAppearance)) - parentView.dd.sessionReplayOverrides.imagePrivacy = parentImagePrivacy + parentView.dd.sessionReplayPrivacyOverrides.imagePrivacy = parentImagePrivacy parentView.addSubview(childView) // When diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift index b0fe13cb30..e4b4ae96ed 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotTests.swift @@ -205,7 +205,7 @@ class ViewAttributesTests: XCTestCase { let childView = UIView.mock(withFixture: .visible(.someAppearance)) let parentView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) - parentView.dd.sessionReplayOverrides.hide = true + parentView.dd.sessionReplayPrivacyOverrides.hide = true let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) @@ -222,8 +222,8 @@ class ViewAttributesTests: XCTestCase { let childView = UIView.mock(withFixture: .visible(.someAppearance)) parentView.addSubview(childView) - parentView.dd.sessionReplayOverrides.hide = true - childView.dd.sessionReplayOverrides.hide = false + parentView.dd.sessionReplayPrivacyOverrides.hide = true + childView.dd.sessionReplayPrivacyOverrides.hide = false let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) @@ -241,9 +241,9 @@ class ViewAttributesTests: XCTestCase { parentView.addSubview(childView) let parentOverrides: PrivacyOverrides = .mockRandom() - parentView.dd.sessionReplayOverrides.textAndInputPrivacy = parentOverrides.textAndInputPrivacy - parentView.dd.sessionReplayOverrides.imagePrivacy = parentOverrides.imagePrivacy - parentView.dd.sessionReplayOverrides.touchPrivacy = parentOverrides.touchPrivacy + parentView.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = parentOverrides.textAndInputPrivacy + parentView.dd.sessionReplayPrivacyOverrides.imagePrivacy = parentOverrides.imagePrivacy + parentView.dd.sessionReplayPrivacyOverrides.touchPrivacy = parentOverrides.touchPrivacy let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) let nodes = recorder.record(parentView, in: .mockRandom()) diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index 029c00cfa3..f378c29026 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -17,10 +17,10 @@ class SessionReplayOverridesTests: XCTestCase { let view = UIView() // Then - XCTAssertNil(view.dd.sessionReplayOverrides.textAndInputPrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.imagePrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.hide) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) } func testWithOverrides() { @@ -28,37 +28,37 @@ class SessionReplayOverridesTests: XCTestCase { let view = UIView() // When - view.dd.sessionReplayOverrides.textAndInputPrivacy = .maskAllInputs - view.dd.sessionReplayOverrides.imagePrivacy = .maskAll - view.dd.sessionReplayOverrides.touchPrivacy = .hide - view.dd.sessionReplayOverrides.hide = true + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide + view.dd.sessionReplayPrivacyOverrides.hide = true // Then - XCTAssertEqual(view.dd.sessionReplayOverrides.textAndInputPrivacy, .maskAllInputs) - XCTAssertEqual(view.dd.sessionReplayOverrides.imagePrivacy, .maskAll) - XCTAssertEqual(view.dd.sessionReplayOverrides.touchPrivacy, .hide) - XCTAssertEqual(view.dd.sessionReplayOverrides.hide, true) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy, .maskAllInputs) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.imagePrivacy, .maskAll) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.touchPrivacy, .hide) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, true) } func testRemovingOverrides() { // Given let view = UIView() - view.dd.sessionReplayOverrides.textAndInputPrivacy = .maskAllInputs - view.dd.sessionReplayOverrides.imagePrivacy = .maskAll - view.dd.sessionReplayOverrides.touchPrivacy = .hide - view.dd.sessionReplayOverrides.hide = true + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide + view.dd.sessionReplayPrivacyOverrides.hide = true // When - view.dd.sessionReplayOverrides.textAndInputPrivacy = nil - view.dd.sessionReplayOverrides.imagePrivacy = nil - view.dd.sessionReplayOverrides.touchPrivacy = nil - view.dd.sessionReplayOverrides.hide = nil + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = nil + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = nil + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = nil + view.dd.sessionReplayPrivacyOverrides.hide = nil // Then - XCTAssertNil(view.dd.sessionReplayOverrides.textAndInputPrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.imagePrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.touchPrivacy) - XCTAssertNil(view.dd.sessionReplayOverrides.hide) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) } // MARK: Privacy overrides taking precedence over global settings @@ -160,6 +160,9 @@ class SessionReplayOverridesTests: XCTestCase { // Given let childOverrides: PrivacyOverrides = .mockAny() let parentOverrides: PrivacyOverrides = .mockRandom() + /// We explicitly set `hide` to `true` in the parent override because the child’s `hide` is `nil`. + /// In the merge logic, `true` takes precedence, and `false` behaves the same as `nil`, meaning no override. + parentOverrides.hide = true // When let merged = SessionReplayPrivacyOverrides.merge(childOverrides, with: parentOverrides) From f798be8a01482b6ac48f7b63df67d094d77b70a2 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 23 Oct 2024 12:09:23 +0200 Subject: [PATCH 33/43] Address CR comments on hide override --- .../UIView+SessionReplayPrivacyOverrides+objc.swift | 11 ++--------- .../Tests/DDSessionReplayOverridesTests.swift | 10 +++++----- .../Tests/Mocks/PrivacyOverridesMock+objc.swift | 8 ++------ 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift index 2f57516a92..b91cfe65b2 100644 --- a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift +++ b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift @@ -54,15 +54,8 @@ public final class objc_SessionReplayPrivacyOverrides: NSObject { /// Hidden privacy override (e.g., mark a view as hidden, rendering it as an opaque wireframe in replays). @objc public var hide: NSNumber? { - get { - guard let hide = _swift.hide else { - return nil - } - return NSNumber(value: hide) - } - set { - _swift.hide = newValue?.boolValue - } + get { _swift.hide.map { NSNumber(value: $0) } } + set { _swift.hide = newValue?.boolValue } } } diff --git a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift index d8b4d9135d..9ed8a59bb7 100644 --- a/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -80,7 +80,7 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() - let hidePrivacy = NSNumber.mockRandomHidePrivacy() + let hidePrivacy = NSNumber.mockRandomBoolean() // When let overrides = objc_SessionReplayPrivacyOverrides(view: UIView()) @@ -114,7 +114,7 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() - let hidePrivacy = NSNumber.mockRandomHidePrivacy() + let hidePrivacy = NSNumber.mockRandomBoolean() // When view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy @@ -147,7 +147,7 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() - let hidePrivacy = NSNumber.mockRandomHidePrivacy() + let hidePrivacy = NSNumber.mockRandomBoolean() // When (set in Swift) view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy._swift @@ -168,7 +168,7 @@ class DDSessionReplayOverrideTests: XCTestCase { let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() - let hidePrivacy = NSNumber.mockRandomHidePrivacy() + let hidePrivacy = NSNumber.mockRandomBoolean() // When (set in ObjC) view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = textAndInputPrivacy @@ -192,7 +192,7 @@ class DDSessionReplayOverrideTests: XCTestCase { tempView.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = .mockRandom() tempView.ddSessionReplayPrivacyOverrides.imagePrivacy = .mockRandom() tempView.ddSessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() - tempView.ddSessionReplayPrivacyOverrides.hide = NSNumber.mockRandomHidePrivacy() + tempView.ddSessionReplayPrivacyOverrides.hide = NSNumber.mockRandomBoolean() } XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.textAndInputPrivacy) diff --git a/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift index 807a011ee9..c8cb566900 100644 --- a/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift +++ b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift @@ -42,12 +42,8 @@ extension objc_TouchPrivacyLevelOverride: AnyMockable, RandomMockable { } extension NSNumber { - static func mockAnyHidePrivacy() -> NSNumber? { - return NSNumber(value: true) - } - - static func mockRandomHidePrivacy() -> NSNumber? { - return [true, false].randomElement().map { NSNumber(value: $0) } ?? nil + static func mockRandomBoolean() -> NSNumber? { + NSNumber(value: [true, false].randomElement()!) } } #endif From 30f5efbf50111265bc3a6f6f3c47e860da82d386 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Fri, 18 Oct 2024 10:55:16 +0200 Subject: [PATCH 34/43] RUM-6569 SR FGM - Touch override logic --- .../Utils/SnapshotTestCase.swift | 3 +- .../Feature/SessionReplayFeature.swift | 3 +- .../Sources/Recorder/Recorder.swift | 8 +- .../WindowTouchSnapshotProducer.swift | 78 ++++++++-- .../Tests/Mocks/UIKitMocks.swift | 8 +- .../WindowTouchSnapshotProducerTests.swift | 145 +++++++++++++++++- 6 files changed, 226 insertions(+), 19 deletions(-) diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift index c05522675b..3124518a68 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift @@ -140,7 +140,8 @@ internal class SnapshotTestCase: XCTestCase { let recorder = try Recorder( snapshotProcessor: snapshotProcessor, - additionalNodeRecorders: [] + additionalNodeRecorders: [], + globalTouchPrivacy: .show ) // Set up wireframes interception: diff --git a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift index 77166ca943..f68e1cd0f3 100644 --- a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift +++ b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift @@ -45,7 +45,8 @@ internal class SessionReplayFeature: SessionReplayConfiguration, DatadogRemoteFe let recorder = try Recorder( snapshotProcessor: snapshotProcessor, - additionalNodeRecorders: configuration._additionalNodeRecorders + additionalNodeRecorders: configuration._additionalNodeRecorders, + globalTouchPrivacy: configuration.touchPrivacyLevel ) let scheduler = MainThreadScheduler(interval: 0.1) diff --git a/DatadogSessionReplay/Sources/Recorder/Recorder.swift b/DatadogSessionReplay/Sources/Recorder/Recorder.swift index 6a2d6b25d3..c6fc2cd3d3 100644 --- a/DatadogSessionReplay/Sources/Recorder/Recorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/Recorder.swift @@ -71,7 +71,8 @@ public class Recorder: Recording { convenience init( snapshotProcessor: SnapshotProcessing, - additionalNodeRecorders: [NodeRecorder] + additionalNodeRecorders: [NodeRecorder], + globalTouchPrivacy: TouchPrivacyLevel ) throws { let windowObserver = KeyWindowObserver() let viewTreeSnapshotProducer = WindowViewTreeSnapshotProducer( @@ -79,7 +80,8 @@ public class Recorder: Recording { snapshotBuilder: ViewTreeSnapshotBuilder(additionalNodeRecorders: additionalNodeRecorders) ) let touchSnapshotProducer = WindowTouchSnapshotProducer( - windowObserver: windowObserver + windowObserver: windowObserver, + globalTouchPrivacy: globalTouchPrivacy ) self.init( @@ -117,7 +119,7 @@ public class Recorder: Recording { return } - let touchSnapshot = recorderContext.touchPrivacy == .show ? touchSnapshotProducer.takeSnapshot(context: recorderContext) : nil + let touchSnapshot = touchSnapshotProducer.takeSnapshot(context: recorderContext) snapshotProcessor.process(viewTreeSnapshot: viewTreeSnapshot, touchSnapshot: touchSnapshot) } } diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index f6f03dea35..58cdac8923 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -14,12 +14,20 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle private let windowObserver: AppWindowObserver /// Generates persisted IDs for `UITouch` objects. private let idsGenerator = TouchIdentifierGenerator() + /// Global touch privacy setting + private var globalTouchPrivacy: TouchPrivacyLevel + /// Keeps track of the privacy override for each touch event + private var overrideForTouch = [TouchIdentifier: TouchPrivacyLevel?]() /// Touches recorded since last call to `takeSnapshot()` private var buffer: [TouchSnapshot.Touch] = [] - init(windowObserver: AppWindowObserver) { + init( + windowObserver: AppWindowObserver, + globalTouchPrivacy: TouchPrivacyLevel + ) { self.windowObserver = windowObserver + self.globalTouchPrivacy = globalTouchPrivacy } func takeSnapshot(context: Recorder.Context) -> TouchSnapshot? { @@ -30,6 +38,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle return touch } } + guard let firstTouch = buffer.first else { return nil } @@ -40,7 +49,12 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle // MARK: - UIEventHandler - /// Delegate of `UIApplicationSwizzler` - called each time when `UIApplication` receives an `UIEvent`. + /// Delegate of `UIApplicationSwizzler`. + /// This method is triggered whenever `UIApplication` receives an `UIEvent`. + /// It captures `UITouch` events, determines if the touch should be recorded + /// based on the view hierarchy's touch privacy settings, and appends valid + /// touches to a buffer for later snapshot creation. Touches are only recorded + /// if they are not excluded by any `touchPrivacy` override set on the view or its ancestors. func notify_sendEvent(application: UIApplication, event: UIEvent) { guard event.type == .touches, let window = windowObserver.relevantWindow, @@ -54,16 +68,62 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle continue } - buffer.append( - TouchSnapshot.Touch( - id: idsGenerator.touchIdentifier(for: touch), - phase: phase, - date: Date(), - position: touch.location(in: window) + let touchId = idsGenerator.touchIdentifier(for: touch) + + // Capture the touch privacy override when the touch begins + if phase == .down, + let privacyOverride = resolveTouchOverride(for: touch) { + overrideForTouch[touchId] = privacyOverride + } + + if shouldRecordTouch(touchId) { + buffer.append( + TouchSnapshot.Touch( + id: touchId, + phase: phase, + date: Date(), + position: touch.location(in: window) + ) ) - ) + } + + // Clean up cache when the touch ends + if phase == .up { + overrideForTouch.removeValue(forKey: touchId) + } } } + + /// Determines whether the touch event should be recorded based on its privacy override and the global privacy settings. + /// If the touch has a specific privacy override, that override is used. + /// Otherwise, the global touch privacy setting is applied. + /// - Parameter touchId: The unique identifier for the touch event. + /// - Returns: `true` if the touch should be recorded, `false` otherwise. + internal func shouldRecordTouch(_ touchId: TouchIdentifier) -> Bool { + let override = overrideForTouch[touchId] ?? nil + let privacy: TouchPrivacyLevel = override ?? globalTouchPrivacy + return privacy == .show + } + + /// Resolves the touch privacy override for the given touch by traversing the view hierarchy. + /// It checks the `dd.sessionReplayOverrides.touchPrivacy` property for the view where the touch occurred + /// and its ancestors, if needed. The first non-nil override encountered is returned. + /// - Parameter touch: The touch event to check. + /// - Returns: The `TouchPrivacyLevel` for the view, or `nil` if no override is found. + internal func resolveTouchOverride(for touch: UITouch) -> TouchPrivacyLevel? { + guard let initialView = touch.view else { + return nil + } + + var view: UIView? = initialView + while view != nil { + if let touchPrivacy = view?.dd.sessionReplayOverrides.touchPrivacy { + return touchPrivacy + } + view = view?.superview + } + return nil + } } internal extension UITouch.Phase { diff --git a/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift b/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift index c14abffd1c..3b83c2ce6c 100644 --- a/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift @@ -46,10 +46,12 @@ extension UIView: AnyMockable, RandomMockable { class UITouchMock: UITouch { var _phase: UITouch.Phase var _location: CGPoint + var _mockedView: UIView - init(phase: UITouch.Phase = .began, location: CGPoint = .zero) { + init(phase: UITouch.Phase = .began, location: CGPoint = .zero, view: UIView = UIView()) { self._phase = phase self._location = location + self._mockedView = view } override var phase: UITouch.Phase { @@ -60,6 +62,10 @@ class UITouchMock: UITouch { override func location(in view: UIView?) -> CGPoint { return _location } + + override var view: UIView { + return _mockedView + } } class UITouchEventMock: UIEvent { diff --git a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift index 5c282bd317..22eeb7930c 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -22,7 +22,8 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockAny() ) // When @@ -51,7 +52,8 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockAny() ) // When @@ -74,7 +76,8 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockAny() ) // When @@ -107,7 +110,8 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let touchEvent1 = UITouchEventMock(touches: (0..<2).map { _ in UITouchMock(phase: .moved) }) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockAny() ) // When @@ -117,5 +121,138 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Then XCTAssertGreaterThan(snapshot1!.date, Date()) } + + // MARK: - Touch Override Tests + func testResolveTouchOverride_whenViewHasNoOverride_returnsNil() { + // Given + let view = UIView(frame: .mockRandom()) + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + let override = producer.resolveTouchOverride(for: touch) + + // Then + XCTAssertNil(override, "Touch privacy override should be `nil` if view and its ancestors have no overrides") + } + + func testResolveTouchOverride_whenParentViewHasOverride_returnsOverride() { + // Given + let parentView = UIView(frame: .mockRandom()) + let touchOverride: TouchPrivacyLevel = .mockRandom() + parentView.dd.sessionReplayOverrides.touchPrivacy = touchOverride + + let childView = UIView(frame: .mockRandom()) + parentView.addSubview(childView) + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: childView) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + let override = producer.resolveTouchOverride(for: touch) + + // Then + XCTAssertEqual(override, touchOverride, "Touch privacy override should be inherited from the parent view") + } + + func testWhenViewHasTouchOverrideSetToHide_touchesAreNotRecorded() { + // Given + let view = UIView(frame: .mockRandom()) + view.dd.sessionReplayOverrides.touchPrivacy = .hide + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + producer.notify_sendEvent(application: mockApplication, event: touchEvent) + let snapshot = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertNil(snapshot, "Touches in a view with touch privacy set to `.hide` should not be recorded") + } + + func testWhenParentViewHasTouchOverrideSetToHide_touchesInChildViewsAreNotRecorded() { + // Given + let parentView = UIView(frame: .mockRandom()) + parentView.dd.sessionReplayOverrides.touchPrivacy = .hide + + let childView = UIView(frame: .mockRandom()) + parentView.addSubview(childView) + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: childView) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + producer.notify_sendEvent(application: mockApplication, event: touchEvent) + let snapshot = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertNil(snapshot, "Touches in a child view of a parent with touch privacy set to `.hide` should not be recorded") + } + + func testWhenViewHasTouchOverrideSetToShow_touchesAreRecorded() { + // Given + let view = UIView(frame: .mockRandom()) + view.dd.sessionReplayOverrides.touchPrivacy = .show + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + producer.notify_sendEvent(application: mockApplication, event: touchEvent) + let snapshot = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertNotNil(snapshot, "Touches in a view with touch privacy override `.show` should be recorded even when global setting is `.hide`") + XCTAssertEqual(snapshot?.touches.count, 1, "It should record one touch event") + } + + func testWhenParentViewHasTouchOverrideSetToShow_touchesInChildViewsAreRecorded() { + // Given + let parentView = UIView(frame: .mockRandom()) + parentView.dd.sessionReplayOverrides.touchPrivacy = .show + + let childView = UIView(frame: .mockRandom()) + parentView.addSubview(childView) + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: childView) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver, + globalTouchPrivacy: .mockRandom() + ) + + // When + producer.notify_sendEvent(application: mockApplication, event: touchEvent) + let snapshot = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertNotNil(snapshot, "Touches in a view with touch privacy override `.show` should be recorded even when global setting is `.hide`") + XCTAssertEqual(snapshot?.touches.count, 1, "It should record one touch event") + } } #endif From ebbd5bf34225e5443e8e066cde7201cef214bad3 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Tue, 22 Oct 2024 15:26:57 +0200 Subject: [PATCH 35/43] RUM-6569 Deal with overrides in takeSnapshot --- .../Utils/SnapshotTestCase.swift | 3 +- .../Feature/SessionReplayFeature.swift | 3 +- .../Sources/Recorder/Recorder.swift | 8 +-- .../TouchSnapshot/TouchSnapshot.swift | 2 + .../WindowTouchSnapshotProducer.swift | 52 ++++++++++--------- .../Tests/Mocks/RecorderMocks.swift | 6 ++- .../Processor/SnapshotProcessorTests.swift | 3 +- .../WindowTouchSnapshotProducerTests.swift | 30 ++++------- 8 files changed, 49 insertions(+), 58 deletions(-) diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift index 3124518a68..c05522675b 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/Utils/SnapshotTestCase.swift @@ -140,8 +140,7 @@ internal class SnapshotTestCase: XCTestCase { let recorder = try Recorder( snapshotProcessor: snapshotProcessor, - additionalNodeRecorders: [], - globalTouchPrivacy: .show + additionalNodeRecorders: [] ) // Set up wireframes interception: diff --git a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift index f68e1cd0f3..77166ca943 100644 --- a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift +++ b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift @@ -45,8 +45,7 @@ internal class SessionReplayFeature: SessionReplayConfiguration, DatadogRemoteFe let recorder = try Recorder( snapshotProcessor: snapshotProcessor, - additionalNodeRecorders: configuration._additionalNodeRecorders, - globalTouchPrivacy: configuration.touchPrivacyLevel + additionalNodeRecorders: configuration._additionalNodeRecorders ) let scheduler = MainThreadScheduler(interval: 0.1) diff --git a/DatadogSessionReplay/Sources/Recorder/Recorder.swift b/DatadogSessionReplay/Sources/Recorder/Recorder.swift index c6fc2cd3d3..94862aaa11 100644 --- a/DatadogSessionReplay/Sources/Recorder/Recorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/Recorder.swift @@ -71,18 +71,14 @@ public class Recorder: Recording { convenience init( snapshotProcessor: SnapshotProcessing, - additionalNodeRecorders: [NodeRecorder], - globalTouchPrivacy: TouchPrivacyLevel + additionalNodeRecorders: [NodeRecorder] ) throws { let windowObserver = KeyWindowObserver() let viewTreeSnapshotProducer = WindowViewTreeSnapshotProducer( windowObserver: windowObserver, snapshotBuilder: ViewTreeSnapshotBuilder(additionalNodeRecorders: additionalNodeRecorders) ) - let touchSnapshotProducer = WindowTouchSnapshotProducer( - windowObserver: windowObserver, - globalTouchPrivacy: globalTouchPrivacy - ) + let touchSnapshotProducer = WindowTouchSnapshotProducer(windowObserver: windowObserver) self.init( uiApplicationSwizzler: try UIApplicationSwizzler(handler: touchSnapshotProducer), diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/TouchSnapshot/TouchSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/TouchSnapshot/TouchSnapshot.swift index 3bcd9089e3..52d7e1be25 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/TouchSnapshot/TouchSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/TouchSnapshot/TouchSnapshot.swift @@ -21,6 +21,8 @@ internal struct TouchSnapshot { var date: Date /// The position of this touch in application window. let position: CGPoint + /// The touch override associated with the touch's view + let touchOverride: TouchPrivacyLevel? } enum TouchPhase { diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index 58cdac8923..f1c046c75e 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -14,20 +14,16 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle private let windowObserver: AppWindowObserver /// Generates persisted IDs for `UITouch` objects. private let idsGenerator = TouchIdentifierGenerator() - /// Global touch privacy setting - private var globalTouchPrivacy: TouchPrivacyLevel /// Keeps track of the privacy override for each touch event - private var overrideForTouch = [TouchIdentifier: TouchPrivacyLevel?]() + private var overrideForTouch: [TouchIdentifier: TouchPrivacyLevel?] = [:] /// Touches recorded since last call to `takeSnapshot()` private var buffer: [TouchSnapshot.Touch] = [] init( - windowObserver: AppWindowObserver, - globalTouchPrivacy: TouchPrivacyLevel + windowObserver: AppWindowObserver ) { self.windowObserver = windowObserver - self.globalTouchPrivacy = globalTouchPrivacy } func takeSnapshot(context: Recorder.Context) -> TouchSnapshot? { @@ -39,6 +35,19 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle } } + // Filter the buffer to only include touches that should be recorded + buffer = buffer.filter { touch in + let shouldRecord = shouldRecordTouch(touch.id, in: context) + + // Clean up cache when the touch ends + if touch.phase == .up { + overrideForTouch.removeValue(forKey: touch.id) + } + + // Return whether this touch should be recorded + return shouldRecord + } + guard let firstTouch = buffer.first else { return nil } @@ -71,26 +80,19 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle let touchId = idsGenerator.touchIdentifier(for: touch) // Capture the touch privacy override when the touch begins - if phase == .down, - let privacyOverride = resolveTouchOverride(for: touch) { + if phase == .down, let privacyOverride = resolveTouchOverride(for: touch) { overrideForTouch[touchId] = privacyOverride } - if shouldRecordTouch(touchId) { - buffer.append( - TouchSnapshot.Touch( - id: touchId, - phase: phase, - date: Date(), - position: touch.location(in: window) - ) + buffer.append( + TouchSnapshot.Touch( + id: touchId, + phase: phase, + date: Date(), + position: touch.location(in: window), + touchOverride: overrideForTouch[touchId].flatMap { $0 } ) - } - - // Clean up cache when the touch ends - if phase == .up { - overrideForTouch.removeValue(forKey: touchId) - } + ) } } @@ -99,9 +101,9 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle /// Otherwise, the global touch privacy setting is applied. /// - Parameter touchId: The unique identifier for the touch event. /// - Returns: `true` if the touch should be recorded, `false` otherwise. - internal func shouldRecordTouch(_ touchId: TouchIdentifier) -> Bool { - let override = overrideForTouch[touchId] ?? nil - let privacy: TouchPrivacyLevel = override ?? globalTouchPrivacy + internal func shouldRecordTouch(_ touchId: TouchIdentifier, in context: Recorder.Context + ) -> Bool { + let privacy: TouchPrivacyLevel = overrideForTouch[touchId].flatMap { $0 } ?? context.touchPrivacy return privacy == .show } diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index e067e18135..1649624184 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -433,7 +433,8 @@ extension TouchSnapshot.Touch: AnyMockable, RandomMockable { id: .mockRandom(), phase: [.down, .move, .up].randomElement()!, date: .mockRandom(), - position: .mockRandom() + position: .mockRandom(), + touchOverride: nil ) } @@ -447,7 +448,8 @@ extension TouchSnapshot.Touch: AnyMockable, RandomMockable { id: id, phase: phase, date: date, - position: position + position: position, + touchOverride: nil ) } } diff --git a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift index 0e86b3ec46..b8b313b76c 100644 --- a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift @@ -502,7 +502,8 @@ class SnapshotProcessorTests: XCTestCase { id: .mockRandom(min: 0, max: TouchIdentifier(numberOfTouches)), phase: [.down, .move, .up].randomElement()!, date: startTime.addingTimeInterval(Double(index) * (dt / Double(numberOfTouches))), - position: .mockRandom() + position: .mockRandom(), + touchOverride: nil ) } ) diff --git a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift index 22eeb7930c..1af4346625 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -22,8 +22,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockAny() + windowObserver: mockWindowObserver ) // When @@ -52,8 +51,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockAny() + windowObserver: mockWindowObserver ) // When @@ -76,8 +74,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockAny() + windowObserver: mockWindowObserver ) // When @@ -110,8 +107,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let touchEvent1 = UITouchEventMock(touches: (0..<2).map { _ in UITouchMock(phase: .moved) }) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockAny() + windowObserver: mockWindowObserver ) // When @@ -129,8 +125,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When @@ -152,8 +147,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touch = UITouchMock(phase: .began, location: .mockRandom(), view: childView) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When @@ -172,8 +166,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touchEvent = UITouchEventMock(touches: [touch]) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When @@ -196,8 +189,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touchEvent = UITouchEventMock(touches: [touch]) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When @@ -217,8 +209,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touchEvent = UITouchEventMock(touches: [touch]) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When @@ -242,8 +233,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { let touchEvent = UITouchEventMock(touches: [touch]) let producer = WindowTouchSnapshotProducer( - windowObserver: mockWindowObserver, - globalTouchPrivacy: .mockRandom() + windowObserver: mockWindowObserver ) // When From 380aec5e8300dadc8b974f9268558bbd5991c251 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 23 Oct 2024 15:45:36 +0200 Subject: [PATCH 36/43] RUM-6569 Edit logic + Cache cleanup --- .../WindowTouchSnapshotProducer.swift | 33 +++++----- .../WindowTouchSnapshotProducerTests.swift | 62 ++++++++++++++++++- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index f1c046c75e..b593c594e9 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -15,7 +15,9 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle /// Generates persisted IDs for `UITouch` objects. private let idsGenerator = TouchIdentifierGenerator() /// Keeps track of the privacy override for each touch event - private var overrideForTouch: [TouchIdentifier: TouchPrivacyLevel?] = [:] + internal var overrideForTouch: [TouchIdentifier: (privacyLevel: TouchPrivacyLevel, timestamp: Date)] = [:] + /// Timeout duration for cleaning up stale touches + internal let touchTimeout: TimeInterval = 10.0 // in seconds /// Touches recorded since last call to `takeSnapshot()` private var buffer: [TouchSnapshot.Touch] = [] @@ -27,16 +29,20 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle } func takeSnapshot(context: Recorder.Context) -> TouchSnapshot? { - if let offset = context.viewServerTimeOffset { - buffer = buffer.compactMap { - var touch = $0 - touch.date.addTimeInterval(offset) - return touch - } + let currentTime = Date() + // Remove stale entries from the cache to handle cases where a touch + // never reaches an `.end` phase, preventing leftover entries. + overrideForTouch = overrideForTouch.filter { _, value in + currentTime.timeIntervalSince(value.timestamp) < touchTimeout } - // Filter the buffer to only include touches that should be recorded - buffer = buffer.filter { touch in + buffer = buffer.compactMap { touch in + var updatedTouch = touch + if let offset = context.viewServerTimeOffset { + updatedTouch.date.addTimeInterval(offset) + } + + // Filter the buffer to only include touches that should be recorded let shouldRecord = shouldRecordTouch(touch.id, in: context) // Clean up cache when the touch ends @@ -44,8 +50,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle overrideForTouch.removeValue(forKey: touch.id) } - // Return whether this touch should be recorded - return shouldRecord + return shouldRecord ? updatedTouch : nil } guard let firstTouch = buffer.first else { @@ -81,7 +86,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle // Capture the touch privacy override when the touch begins if phase == .down, let privacyOverride = resolveTouchOverride(for: touch) { - overrideForTouch[touchId] = privacyOverride + overrideForTouch[touchId] = (privacyLevel: privacyOverride, timestamp: Date()) } buffer.append( @@ -90,7 +95,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle phase: phase, date: Date(), position: touch.location(in: window), - touchOverride: overrideForTouch[touchId].flatMap { $0 } + touchOverride: overrideForTouch[touchId]?.privacyLevel ) ) } @@ -103,7 +108,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle /// - Returns: `true` if the touch should be recorded, `false` otherwise. internal func shouldRecordTouch(_ touchId: TouchIdentifier, in context: Recorder.Context ) -> Bool { - let privacy: TouchPrivacyLevel = overrideForTouch[touchId].flatMap { $0 } ?? context.touchPrivacy + let privacy: TouchPrivacyLevel = overrideForTouch[touchId]?.privacyLevel ?? context.touchPrivacy return privacy == .show } diff --git a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift index 1af4346625..9d96639a66 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -118,7 +118,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { XCTAssertGreaterThan(snapshot1!.date, Date()) } - // MARK: - Touch Override Tests + // MARK: - Touch Override View Tests func testResolveTouchOverride_whenViewHasNoOverride_returnsNil() { // Given let view = UIView(frame: .mockRandom()) @@ -244,5 +244,65 @@ class WindowTouchSnapshotProducerTests: XCTestCase { XCTAssertNotNil(snapshot, "Touches in a view with touch privacy override `.show` should be recorded even when global setting is `.hide`") XCTAssertEqual(snapshot?.touches.count, 1, "It should record one touch event") } + + // MARK: Touch Override Cache Tests + func testEndedTouchEntriesAreCleaned() { + // Given + let view = UIView(frame: .mockRandom()) + view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + let touch = UITouchMock(view: view) + let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) + + // Simulate a normal touch flow, + // with `.began` and `.ended` phases + touch.phase = .began + producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) + touch.phase = .ended + producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) + + // When + _ = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertEqual(producer.overrideForTouch.count, 0, "The touch entry should not be present anymore after it ended") + } + + func testStaleTouchEntriesAreCleanedAfterTimeout() { + // Given + let view = UIView(frame: .mockRandom()) + view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) + + // Simulate a touch event with a `.began` phase + // but which doesn't end within the timeout + producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) + let touchId = producer.overrideForTouch.keys.first! + producer.overrideForTouch[touchId] = (privacyLevel: .show, timestamp: Date().addingTimeInterval(-(producer.touchTimeout + 1))) + + // When + _ = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertEqual(producer.overrideForTouch.count, 0, "There should be no remaining stale touch entries in overrideForTouch") + } + + func testOngoingTouchEntriesAreNotCleanedPrematurely() { + // Given + let view = UIView(frame: .mockRandom()) + view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) + + // Simulate a touch event that is still ongoing, + // with a `.began` phase + producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) + + // When + _ = producer.takeSnapshot(context: .mockAny()) + + // Then + XCTAssertEqual(producer.overrideForTouch.count, 1, "The ongoing touch entry should still be present in overrideForTouch") + } } #endif From 4eae1dc896e5c3405fd89fd3b6a00d74d6eb5a82 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Wed, 23 Oct 2024 19:35:45 +0200 Subject: [PATCH 37/43] Fix var name --- .../WindowTouchSnapshotProducer.swift | 4 ++-- .../Sources/SessionReplayPrivacyOverrides.swift | 6 +++--- .../WindowTouchSnapshotProducerTests.swift | 16 ++++++++-------- .../Tests/SessionReplayOverrideTests.swift | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index b593c594e9..744e71fbfb 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -113,7 +113,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle } /// Resolves the touch privacy override for the given touch by traversing the view hierarchy. - /// It checks the `dd.sessionReplayOverrides.touchPrivacy` property for the view where the touch occurred + /// It checks the `dd.sessionReplayPrivacyOverrides.touchPrivacy` property for the view where the touch occurred /// and its ancestors, if needed. The first non-nil override encountered is returned. /// - Parameter touch: The touch event to check. /// - Returns: The `TouchPrivacyLevel` for the view, or `nil` if no override is found. @@ -124,7 +124,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle var view: UIView? = initialView while view != nil { - if let touchPrivacy = view?.dd.sessionReplayOverrides.touchPrivacy { + if let touchPrivacy = view?.dd.sessionReplayPrivacyOverrides.touchPrivacy { return touchPrivacy } view = view?.superview diff --git a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift index afaeaf7950..6e4b949a2a 100644 --- a/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift @@ -10,10 +10,10 @@ import DatadogInternal // MARK: - DatadogExtension for UIView -/// Extension to provide access to `SessionReplayOverrides` for any `UIView`. +/// Extension to provide access to `SessionReplayPrivacyOverrides` for any `UIView`. extension DatadogExtension where ExtendedType: UIView { /// Provides access to Session Replay override settings for the view. - /// Usage: `myView.dd.sessionReplayOverrides.textAndInputPrivacy = .maskNone`. + /// Usage: `myView.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskNone`. public var sessionReplayPrivacyOverrides: SessionReplayPrivacyOverrides { return SessionReplayPrivacyOverrides(self.type) } @@ -26,7 +26,7 @@ private var associatedImagePrivacyKey: UInt8 = 4 private var associatedTouchPrivacyKey: UInt8 = 5 private var associatedHiddenPrivacyKey: UInt8 = 6 -// MARK: - SessionReplayOverrides +// MARK: - SessionReplayPrivacyOverrides /// `UIView` extension to manage the Session Replay privacy override settings. public final class SessionReplayPrivacyOverrides { diff --git a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift index 9d96639a66..c6c334a913 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -139,7 +139,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Given let parentView = UIView(frame: .mockRandom()) let touchOverride: TouchPrivacyLevel = .mockRandom() - parentView.dd.sessionReplayOverrides.touchPrivacy = touchOverride + parentView.dd.sessionReplayPrivacyOverrides.touchPrivacy = touchOverride let childView = UIView(frame: .mockRandom()) parentView.addSubview(childView) @@ -160,7 +160,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testWhenViewHasTouchOverrideSetToHide_touchesAreNotRecorded() { // Given let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayOverrides.touchPrivacy = .hide + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) let touchEvent = UITouchEventMock(touches: [touch]) @@ -180,7 +180,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testWhenParentViewHasTouchOverrideSetToHide_touchesInChildViewsAreNotRecorded() { // Given let parentView = UIView(frame: .mockRandom()) - parentView.dd.sessionReplayOverrides.touchPrivacy = .hide + parentView.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide let childView = UIView(frame: .mockRandom()) parentView.addSubview(childView) @@ -203,7 +203,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testWhenViewHasTouchOverrideSetToShow_touchesAreRecorded() { // Given let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayOverrides.touchPrivacy = .show + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .show let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) let touchEvent = UITouchEventMock(touches: [touch]) @@ -224,7 +224,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testWhenParentViewHasTouchOverrideSetToShow_touchesInChildViewsAreRecorded() { // Given let parentView = UIView(frame: .mockRandom()) - parentView.dd.sessionReplayOverrides.touchPrivacy = .show + parentView.dd.sessionReplayPrivacyOverrides.touchPrivacy = .show let childView = UIView(frame: .mockRandom()) parentView.addSubview(childView) @@ -249,7 +249,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testEndedTouchEntriesAreCleaned() { // Given let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() let touch = UITouchMock(view: view) let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) @@ -270,7 +270,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testStaleTouchEntriesAreCleanedAfterTimeout() { // Given let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) @@ -290,7 +290,7 @@ class WindowTouchSnapshotProducerTests: XCTestCase { func testOngoingTouchEntriesAreNotCleanedPrematurely() { // Given let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayOverrides.touchPrivacy = .mockRandom() + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift index f378c29026..b33b02a791 100644 --- a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -10,7 +10,7 @@ import UIKit @_spi(Internal) @testable import DatadogSessionReplay -class SessionReplayOverridesTests: XCTestCase { +class SessionReplayPrivacyOverridesTests: XCTestCase { // MARK: Setting overrides func testWhenNoOverrideIsSet_itDefaultsToNil() { // Given From 1de7e86be8109d4d44222015ffcf732cdd413671 Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 24 Oct 2024 10:40:53 +0200 Subject: [PATCH 38/43] RUM-6569 Remove touchTimeout and clean cache logic --- .../WindowTouchSnapshotProducer.swift | 17 ++---- .../WindowTouchSnapshotProducerTests.swift | 60 ------------------- 2 files changed, 4 insertions(+), 73 deletions(-) diff --git a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift index 744e71fbfb..3c1a763fae 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -15,9 +15,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle /// Generates persisted IDs for `UITouch` objects. private let idsGenerator = TouchIdentifierGenerator() /// Keeps track of the privacy override for each touch event - internal var overrideForTouch: [TouchIdentifier: (privacyLevel: TouchPrivacyLevel, timestamp: Date)] = [:] - /// Timeout duration for cleaning up stale touches - internal let touchTimeout: TimeInterval = 10.0 // in seconds + private var overrideForTouch: [TouchIdentifier: TouchPrivacyLevel] = [:] /// Touches recorded since last call to `takeSnapshot()` private var buffer: [TouchSnapshot.Touch] = [] @@ -29,13 +27,6 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle } func takeSnapshot(context: Recorder.Context) -> TouchSnapshot? { - let currentTime = Date() - // Remove stale entries from the cache to handle cases where a touch - // never reaches an `.end` phase, preventing leftover entries. - overrideForTouch = overrideForTouch.filter { _, value in - currentTime.timeIntervalSince(value.timestamp) < touchTimeout - } - buffer = buffer.compactMap { touch in var updatedTouch = touch if let offset = context.viewServerTimeOffset { @@ -86,7 +77,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle // Capture the touch privacy override when the touch begins if phase == .down, let privacyOverride = resolveTouchOverride(for: touch) { - overrideForTouch[touchId] = (privacyLevel: privacyOverride, timestamp: Date()) + overrideForTouch[touchId] = privacyOverride } buffer.append( @@ -95,7 +86,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle phase: phase, date: Date(), position: touch.location(in: window), - touchOverride: overrideForTouch[touchId]?.privacyLevel + touchOverride: overrideForTouch[touchId] ) ) } @@ -108,7 +99,7 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle /// - Returns: `true` if the touch should be recorded, `false` otherwise. internal func shouldRecordTouch(_ touchId: TouchIdentifier, in context: Recorder.Context ) -> Bool { - let privacy: TouchPrivacyLevel = overrideForTouch[touchId]?.privacyLevel ?? context.touchPrivacy + let privacy: TouchPrivacyLevel = overrideForTouch[touchId] ?? context.touchPrivacy return privacy == .show } diff --git a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift index c6c334a913..5c948f0710 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -244,65 +244,5 @@ class WindowTouchSnapshotProducerTests: XCTestCase { XCTAssertNotNil(snapshot, "Touches in a view with touch privacy override `.show` should be recorded even when global setting is `.hide`") XCTAssertEqual(snapshot?.touches.count, 1, "It should record one touch event") } - - // MARK: Touch Override Cache Tests - func testEndedTouchEntriesAreCleaned() { - // Given - let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() - let touch = UITouchMock(view: view) - let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) - - // Simulate a normal touch flow, - // with `.began` and `.ended` phases - touch.phase = .began - producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) - touch.phase = .ended - producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) - - // When - _ = producer.takeSnapshot(context: .mockAny()) - - // Then - XCTAssertEqual(producer.overrideForTouch.count, 0, "The touch entry should not be present anymore after it ended") - } - - func testStaleTouchEntriesAreCleanedAfterTimeout() { - // Given - let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() - let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) - let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) - - // Simulate a touch event with a `.began` phase - // but which doesn't end within the timeout - producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) - let touchId = producer.overrideForTouch.keys.first! - producer.overrideForTouch[touchId] = (privacyLevel: .show, timestamp: Date().addingTimeInterval(-(producer.touchTimeout + 1))) - - // When - _ = producer.takeSnapshot(context: .mockAny()) - - // Then - XCTAssertEqual(producer.overrideForTouch.count, 0, "There should be no remaining stale touch entries in overrideForTouch") - } - - func testOngoingTouchEntriesAreNotCleanedPrematurely() { - // Given - let view = UIView(frame: .mockRandom()) - view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .mockRandom() - let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) - let producer = WindowTouchSnapshotProducer(windowObserver: mockWindowObserver) - - // Simulate a touch event that is still ongoing, - // with a `.began` phase - producer.notify_sendEvent(application: mockApplication, event: UITouchEventMock(touches: [touch])) - - // When - _ = producer.takeSnapshot(context: .mockAny()) - - // Then - XCTAssertEqual(producer.overrideForTouch.count, 1, "The ongoing touch entry should still be present in overrideForTouch") - } } #endif From 95abec5720fd3de5639169722fb60b2171de0ced Mon Sep 17 00:00:00 2001 From: Marie Denis Date: Thu, 24 Oct 2024 11:30:54 +0200 Subject: [PATCH 39/43] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764fd57a8d..d5445ac540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +- [FEATURE] Add Privacy Overrides in Session Replay. See [#2088][] - [IMPROVEMENT] Add ObjC API for the internal logging/telemetry. See [#2073][] # 2.18.0 / 25-09-2024 @@ -777,6 +778,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2040]: https://github.com/DataDog/dd-sdk-ios/pull/2040 [#2050]: https://github.com/DataDog/dd-sdk-ios/pull/2050 [#2073]: https://github.com/DataDog/dd-sdk-ios/pull/2073 +[#2088]: https://github.com/DataDog/dd-sdk-ios/pull/2088 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin [@hengyu]: https://github.com/Hengyu From 823d31249f7bc027aba94736ab02dcad09423958 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Thu, 24 Oct 2024 14:40:48 +0200 Subject: [PATCH 40/43] Bumped version to 2.19.0 --- DatadogAlamofireExtension.podspec | 2 +- DatadogCore.podspec | 2 +- DatadogCore/Sources/Versioning.swift | 2 +- DatadogCrashReporting.podspec | 2 +- DatadogInternal.podspec | 2 +- DatadogLogs.podspec | 2 +- DatadogObjc.podspec | 2 +- DatadogRUM.podspec | 2 +- DatadogSessionReplay.podspec | 2 +- DatadogTrace.podspec | 2 +- DatadogWebViewTracking.podspec | 2 +- TestUtilities.podspec | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/DatadogAlamofireExtension.podspec b/DatadogAlamofireExtension.podspec index 915819a6bb..497555694d 100644 --- a/DatadogAlamofireExtension.podspec +++ b/DatadogAlamofireExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogAlamofireExtension" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "An Official Extensions of Datadog Swift SDK for Alamofire." s.description = <<-DESC The DatadogAlamofireExtension pod is deprecated and will no longer be maintained. diff --git a/DatadogCore.podspec b/DatadogCore.podspec index 5217619262..e4bf9503ed 100644 --- a/DatadogCore.podspec +++ b/DatadogCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogCore" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Official Datadog Swift SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogCore/Sources/Versioning.swift b/DatadogCore/Sources/Versioning.swift index 487c2817a3..ab060cdf20 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.18.0" +internal let __sdkVersion = "2.19.0" diff --git a/DatadogCrashReporting.podspec b/DatadogCrashReporting.podspec index 75d0f8cee6..da33eb1937 100644 --- a/DatadogCrashReporting.podspec +++ b/DatadogCrashReporting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogCrashReporting" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Official Datadog Crash Reporting SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogInternal.podspec b/DatadogInternal.podspec index e41e4623b9..6373cebfbc 100644 --- a/DatadogInternal.podspec +++ b/DatadogInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogInternal" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog Internal Package. This module is not for public use." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogLogs.podspec b/DatadogLogs.podspec index 7315fb9398..af1584f205 100644 --- a/DatadogLogs.podspec +++ b/DatadogLogs.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogLogs" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog Logs Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogObjc.podspec b/DatadogObjc.podspec index 837c50856f..25bddfccba 100644 --- a/DatadogObjc.podspec +++ b/DatadogObjc.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogObjc" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Official Datadog Objective-C SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogRUM.podspec b/DatadogRUM.podspec index 0fa2af40e0..a6824d4fb6 100644 --- a/DatadogRUM.podspec +++ b/DatadogRUM.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogRUM" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog Real User Monitoring Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogSessionReplay.podspec b/DatadogSessionReplay.podspec index d14f361e1e..aff5432cfc 100644 --- a/DatadogSessionReplay.podspec +++ b/DatadogSessionReplay.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogSessionReplay" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Official Datadog Session Replay SDK for iOS." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogTrace.podspec b/DatadogTrace.podspec index 315c0639c7..3304deb021 100644 --- a/DatadogTrace.podspec +++ b/DatadogTrace.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogTrace" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog Trace Module." s.homepage = "https://www.datadoghq.com" diff --git a/DatadogWebViewTracking.podspec b/DatadogWebViewTracking.podspec index 8ccef0f623..d5643959e2 100644 --- a/DatadogWebViewTracking.podspec +++ b/DatadogWebViewTracking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "DatadogWebViewTracking" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog WebView Tracking Module." s.homepage = "https://www.datadoghq.com" diff --git a/TestUtilities.podspec b/TestUtilities.podspec index ce51ca591c..d84443bee2 100644 --- a/TestUtilities.podspec +++ b/TestUtilities.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "TestUtilities" - s.version = "2.18.0" + s.version = "2.19.0" s.summary = "Datadog Testing Utilities. This module is for internal testing and should not be published." s.homepage = "https://www.datadoghq.com" From 5f18cf98ad62d6eb5b671bf3d377010f1d4d0bd7 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Thu, 24 Oct 2024 14:41:58 +0200 Subject: [PATCH 41/43] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5445ac540..0f327debeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +# 2.19.0 / 28-10-2024 + - [FEATURE] Add Privacy Overrides in Session Replay. See [#2088][] - [IMPROVEMENT] Add ObjC API for the internal logging/telemetry. See [#2073][] From 635943ff1baca5db09bb0cfebd056760a7cb164e Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Mon, 28 Oct 2024 13:34:01 +0100 Subject: [PATCH 42/43] chore: Temporarily disable CI Tests as causing test failures was causing: """ Failed to delete `TestsDirectory`: Error Domain=NSCocoaErrorDomain Code=512 ... couldn't be removed." """ --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9205d662c5..96d9aeaa21 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -103,7 +103,7 @@ Unit Tests (iOS): script: - ./tools/runner-setup.sh --xcode "$DEFAULT_XCODE" - make clean repo-setup ENV=ci - - make test-ios-all OS="$DEFAULT_IOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" USE_TEST_VISIBILITY=1 + - make test-ios-all OS="$DEFAULT_IOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" USE_TEST_VISIBILITY=0 Unit Tests (tvOS): stage: test @@ -116,7 +116,7 @@ Unit Tests (tvOS): script: - ./tools/runner-setup.sh --xcode "$DEFAULT_XCODE" - make clean repo-setup ENV=ci - - make test-tvos-all OS="$DEFAULT_TVOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" USE_TEST_VISIBILITY=1 + - make test-tvos-all OS="$DEFAULT_TVOS_OS" PLATFORM="$PLATFORM" DEVICE="$DEVICE" USE_TEST_VISIBILITY=0 UI Tests: stage: ui-test From 02f84998cff0ff418299239cccc6c5a833e19061 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Mon, 28 Oct 2024 16:36:16 +0100 Subject: [PATCH 43/43] Update clean.sh --- tools/clean.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/clean.sh b/tools/clean.sh index d3377b468b..6b31ddadc4 100755 --- a/tools/clean.sh +++ b/tools/clean.sh @@ -18,6 +18,7 @@ clean_dir() { clean_dir ~/Library/Developer/Xcode/DerivedData clean_dir ~/Library/Caches/org.carthage.CarthageKit/dependencies/ +clean_dir ~/Library/org.swift.swiftpm clean_dir ./Carthage/Build clean_dir ./Carthage/Checkouts clean_dir ./IntegrationTests/Pods