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` diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 93c6784955..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 @@ -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/CHANGELOG.md b/CHANGELOG.md index d8a7aecbdb..0f327debeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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][] + # 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 +779,8 @@ 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 +[#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 diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index c931e8ed42..647122b78a 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 */; }; @@ -310,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 */; }; @@ -384,8 +385,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 */; }; @@ -681,10 +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 */; }; + 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 */; }; 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 +710,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 */; }; @@ -839,6 +848,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 */; }; @@ -986,7 +996,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 */; }; @@ -1006,7 +1016,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 */; }; @@ -1203,7 +1213,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 */; }; @@ -1251,7 +1261,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 */; }; @@ -1296,7 +1306,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 */; }; @@ -1705,6 +1714,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 */ @@ -2189,7 +2204,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 +2251,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 +2274,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 = ""; }; @@ -2361,6 +2375,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 = ""; }; @@ -2409,7 +2424,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 = ""; }; @@ -2453,7 +2468,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 = ""; }; @@ -2724,10 +2739,16 @@ 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 /* 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 = ""; }; 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 = ""; }; + 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 = ""; }; @@ -2838,6 +2859,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 = ""; }; @@ -3067,6 +3089,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 */ @@ -3548,7 +3573,10 @@ children = ( 61054E0C2A6EE10A00AAA894 /* SessionReplay.swift */, 61054E0B2A6EE10A00AAA894 /* SessionReplayConfiguration.swift */, + 966253B52C98807400B90B63 /* SessionReplayPrivacyOverrides.swift */, A795069B2B974C8100AC4814 /* SessionReplay+objc.swift */, + 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */, + 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */, 61054E3B2A6EE10A00AAA894 /* Feature */, 61054E482A6EE10A00AAA894 /* Processor */, 61054E0D2A6EE10A00AAA894 /* Recorder */, @@ -3563,8 +3591,11 @@ 61054E022A6EE0DB00AAA894 /* DatadogSessionReplayTests */ = { isa = PBXGroup; children = ( + 96E863712C9C547B0023BF78 /* SessionReplayOverrideTests.swift */, 61054F482A6EE1B900AAA894 /* SessionReplayTests.swift */, 61054F3D2A6EE1B900AAA894 /* SessionReplayConfigurationTests.swift */, + D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, + 96E863752C9C7E800023BF78 /* DDSessionReplayOverridesTests.swift */, 61054F882A6EE1BA00AAA894 /* Feature */, 61054F922A6EE1BA00AAA894 /* Helpers */, 61054F7D2A6EE1BA00AAA894 /* Mocks */, @@ -3614,7 +3645,8 @@ isa = PBXGroup; children = ( 61054E142A6EE10A00AAA894 /* UIImage+SessionReplay.swift */, - 61054E152A6EE10A00AAA894 /* UIKitExtensions.swift */, + D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */, + 61054E152A6EE10A00AAA894 /* UIView+SessionReplay.swift */, 61054E162A6EE10A00AAA894 /* CFType+Safety.swift */, 61054E172A6EE10A00AAA894 /* SystemColors.swift */, 61054E182A6EE10A00AAA894 /* CGRect+ContentFrame.swift */, @@ -3785,7 +3817,6 @@ isa = PBXGroup; children = ( 61054E552A6EE10A00AAA894 /* CGRectExtensions.swift */, - 61054E562A6EE10A00AAA894 /* UIImage+Scaling.swift */, 61054E582A6EE10A00AAA894 /* Queue.swift */, 61054E592A6EE10A00AAA894 /* Errors.swift */, 61054E5A2A6EE10A00AAA894 /* Colors.swift */, @@ -3807,7 +3838,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 +3930,7 @@ 61054F5B2A6EE1BA00AAA894 /* Utilties */ = { isa = PBXGroup; children = ( - 61054F5D2A6EE1BA00AAA894 /* UIKitExtensionsTests.swift */, + 61054F5D2A6EE1BA00AAA894 /* UIView+SessionReplayTests.swift */, 61054F5F2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift */, ); path = Utilties; @@ -3972,6 +4003,7 @@ 61054F7D2A6EE1BA00AAA894 /* Mocks */ = { isa = PBXGroup; children = ( + 96F25A842CC7EB3700459567 /* PrivacyOverridesMock+objc.swift */, 3C33E4062BEE35A7003B2988 /* RUMContextMocks.swift */, 61054F7E2A6EE1BA00AAA894 /* UIKitMocks.swift */, 61054F7F2A6EE1BA00AAA894 /* CoreGraphicsMocks.swift */, @@ -3984,7 +4016,6 @@ 61054F872A6EE1BA00AAA894 /* RUMContextObserverMock.swift */, A74A72862B10CE4100771FEB /* ResourceMocks.swift */, A74A72882B10D95D00771FEB /* MultipartBuilderSpy.swift */, - A71265852B17980C007D63CE /* MockFeature.swift */, A7D952892B28BD94004C79B1 /* ResourceProcessorSpy.swift */, ); path = Mocks; @@ -4281,6 +4312,7 @@ 6111C58025C0080C00F5C4A2 /* RUM */, 6132BF4024A38D0600D7BD17 /* OpenTracing */, D2A434A72A8E3FFB0028E329 /* SessionReplay */, + F603F1252CAE9F760088E6B7 /* DDInternalLogger+objc.swift */, ); name = DatadogObjc; path = ../DatadogObjc/Sources; @@ -4317,8 +4349,8 @@ A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */, 9EE5AD8126205B82001E699E /* DDNSURLSessionDelegateTests.swift */, 3CCCA5C62ABAF5230029D7BD /* DDURLSessionInstrumentationConfigurationTests.swift */, - D2A434AD2A8E426C0028E329 /* DDSessionReplayTests.swift */, 61D03BDE273404BB00367DE0 /* RUM */, + F603F1282CAEA4E90088E6B7 /* DDInternalLoggerTests.swift */, ); path = DatadogObjc; sourceTree = ""; @@ -4628,7 +4660,7 @@ 6141014C251A577D00E3C2D9 /* Actions */ = { isa = PBXGroup; children = ( - 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */, + 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */, ); path = Actions; sourceTree = ""; @@ -4636,6 +4668,7 @@ 6141014D251A578D00E3C2D9 /* Actions */ = { isa = PBXGroup; children = ( + 61193AAD2CB54C7300C3CDF5 /* RUMActionsHandler.swift */, D29D5A4A273BF81500A687C1 /* UIKit */, D29D5A4B273BF82200A687C1 /* SwiftUI */, ); @@ -5240,6 +5273,7 @@ 3C1890132ABDE99200CE9E73 /* DDURLSessionInstrumentationTests+apiTests.m */, A795069D2B974CAA00AC4814 /* DDSessionReplay+apiTests.m */, 6174D6052BFB9D5500EC7469 /* DDWebViewTracking+apiTests.m */, + F603F12D2CAEA7590088E6B7 /* DDInternalLogger+apiTests.m */, ); path = ObjcAPITests; sourceTree = ""; @@ -5340,6 +5374,7 @@ 61C713C52A3CA08B00FA735A /* CoreMocks */ = { isa = PBXGroup; children = ( + A71265852B17980C007D63CE /* MockFeature.swift */, D257954A298ABB04008A1BE5 /* PassthroughCoreMock.swift */, D2160CEF29C0EC4D00FAA9A5 /* SingleFeatureCoreMock.swift */, 61C713CF2A3DEFF900FA735A /* FeatureRegistrationCoreMock.swift */, @@ -6291,7 +6326,7 @@ D29D5A4A273BF81500A687C1 /* UIKit */ = { isa = PBXGroup; children = ( - 6141015A251A601D00E3C2D9 /* UIKitRUMUserActionsHandler.swift */, + 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */, 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */, F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */, ); @@ -8168,13 +8203,13 @@ 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 */, 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 */, @@ -8189,6 +8224,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 */, @@ -8219,7 +8255,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 */, @@ -8282,6 +8317,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 +8334,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 */, @@ -8352,14 +8389,15 @@ 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 */, + 962C41A72CA431370050B747 /* SessionReplayPrivacyOverrides.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 */, + 96F25A832CC7EA4400459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift in Sources */, 61054E7D2A6EE10A00AAA894 /* UITextFieldRecorder.swift in Sources */, 61054E832A6EE10A00AAA894 /* UISwitchRecorder.swift in Sources */, 61054E9A2A6EE10A00AAA894 /* NodesFlattener.swift in Sources */, @@ -8391,8 +8429,10 @@ 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 */, + 96F25A822CC7EA4400459567 /* SessionReplayPrivacyOverrides+objc.swift in Sources */, A70ADCD22B583B1300321BC9 /* UIImageResource.swift in Sources */, 61054E792A6EE10A00AAA894 /* UITextViewRecorder.swift in Sources */, 61054E9B2A6EE10B00AAA894 /* CGRectExtensions.swift in Sources */, @@ -8433,7 +8473,9 @@ 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 */, 61054F952A6EE1BA00AAA894 /* SessionReplayConfigurationTests.swift in Sources */, 61054FAC2A6EE1BA00AAA894 /* CGRect+ContentFrameTests.swift in Sources */, @@ -8443,7 +8485,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 */, @@ -8453,6 +8494,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 */, @@ -8460,16 +8502,17 @@ 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 */, + 962C41A82CA431AA0050B747 /* DDSessionReplayOverridesTests.swift in Sources */, 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, D2056C212BBFE05A0085BC76 /* WireframesBuilderTests.swift in Sources */, 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */, @@ -8775,6 +8818,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 */, @@ -8845,7 +8889,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 */, ); @@ -8891,7 +8935,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 */, @@ -8954,6 +8998,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 */, @@ -9001,6 +9046,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 */, @@ -9109,6 +9155,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 */, @@ -9179,7 +9226,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 */, ); @@ -9225,7 +9272,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 */, @@ -9453,7 +9500,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 */, @@ -9463,6 +9509,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 */, @@ -9492,6 +9539,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 +9621,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/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/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/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..31eb3f13d5 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..c85f1e29a8 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,8 @@ internal class FilesOrchestrator: FilesOrchestratorType { BatchDeletedMetric.uploaderWindowKey: performance.uploaderWindow.toMilliseconds, BatchDeletedMetric.batchAgeKey: batchAge.toMilliseconds, BatchDeletedMetric.batchRemovalReasonKey: deletionReason.toString(), - BatchDeletedMetric.inBackgroundKey: false + 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 0f91d21e0e..b7deb4ce21 100644 --- a/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift +++ b/DatadogCore/Sources/SDKMetrics/BatchMetrics.swift @@ -56,6 +56,8 @@ internal enum BatchDeletedMetric { static let batchRemovalReasonKey = "batch_removal_reason" /// 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 { 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/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..d6a8d88b46 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() ) ) } @@ -68,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", ]) @@ -98,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", ]) @@ -131,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", ]) 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/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/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/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m b/DatadogCore/Tests/DatadogObjc/ObjcAPITests/DDSessionReplay+apiTests.m index c65a6e7113..3fcc49b46b 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; @@ -31,4 +32,49 @@ - (void)testConfigurationWithNewApi { [DDSessionReplay enableWith:configuration]; } +- (void)testStartAndStopRecording { + [DDSessionReplay startRecording]; + [DDSessionReplay stopRecording]; +} + +// MARK: Privacy Overrides +- (void)testSettingAndGettingOverrides { + // Given + UIView *view = [[UIView alloc] init]; + + // When + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayPrivacyOverrides.hide = @YES; + + // Then + 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]; + + // Set initial values + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideMaskAll; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideHide; + view.ddSessionReplayPrivacyOverrides.hide = @YES; + + // When + view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy = DDTextAndInputPrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.imagePrivacy = DDImagePrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.touchPrivacy = DDTouchPrivacyLevelOverrideNone; + view.ddSessionReplayPrivacyOverrides.hide = nil; + + // Then + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.textAndInputPrivacy, DDTextAndInputPrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.imagePrivacy, DDImagePrivacyLevelOverrideNone); + XCTAssertEqual(view.ddSessionReplayPrivacyOverrides.touchPrivacy, DDTouchPrivacyLevelOverrideNone); + XCTAssertNil(view.ddSessionReplayPrivacyOverrides.hide); +} @end 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/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/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 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/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/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/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) } 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/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/DatadogSessionReplay/Sources/Recorder/Recorder.swift b/DatadogSessionReplay/Sources/Recorder/Recorder.swift index 6a2d6b25d3..94862aaa11 100644 --- a/DatadogSessionReplay/Sources/Recorder/Recorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/Recorder.swift @@ -78,9 +78,7 @@ public class Recorder: Recording { windowObserver: windowObserver, snapshotBuilder: ViewTreeSnapshotBuilder(additionalNodeRecorders: additionalNodeRecorders) ) - let touchSnapshotProducer = WindowTouchSnapshotProducer( - windowObserver: windowObserver - ) + let touchSnapshotProducer = WindowTouchSnapshotProducer(windowObserver: windowObserver) self.init( uiApplicationSwizzler: try UIApplicationSwizzler(handler: touchSnapshotProducer), @@ -117,7 +115,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/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 f6f03dea35..3c1a763fae 100644 --- a/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift +++ b/DatadogSessionReplay/Sources/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducer.swift @@ -14,22 +14,36 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle private let windowObserver: AppWindowObserver /// 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] = [:] /// Touches recorded since last call to `takeSnapshot()` private var buffer: [TouchSnapshot.Touch] = [] - init(windowObserver: AppWindowObserver) { + init( + windowObserver: AppWindowObserver + ) { self.windowObserver = windowObserver } func takeSnapshot(context: Recorder.Context) -> TouchSnapshot? { - if let offset = context.viewServerTimeOffset { - buffer = buffer.compactMap { - var touch = $0 - touch.date.addTimeInterval(offset) - return touch + 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 + if touch.phase == .up { + overrideForTouch.removeValue(forKey: touch.id) + } + + return shouldRecord ? updatedTouch : nil } + guard let firstTouch = buffer.first else { return nil } @@ -40,7 +54,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 +73,55 @@ internal class WindowTouchSnapshotProducer: TouchSnapshotProducer, UIEventHandle continue } + 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 + } + buffer.append( TouchSnapshot.Touch( - id: idsGenerator.touchIdentifier(for: touch), + id: touchId, phase: phase, date: Date(), - position: touch.location(in: window) + position: touch.location(in: window), + touchOverride: overrideForTouch[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, in context: Recorder.Context + ) -> Bool { + let privacy: TouchPrivacyLevel = overrideForTouch[touchId] ?? context.touchPrivacy + return privacy == .show + } + + /// Resolves the touch privacy override for the given touch by traversing the view hierarchy. + /// 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. + 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.sessionReplayPrivacyOverrides.touchPrivacy { + return touchPrivacy + } + view = view?.superview + } + return nil + } } internal extension UITouch.Phase { 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/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/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/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 f8aa3eec25..2a677184d4 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.overrides.hide == 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.overrides.hide == 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/ViewAttributes+Copy.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewAttributes+Copy.swift index 08730ff90c..7baba908d7 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: PrivacyOverrides 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..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) + recordRecursively(nodes: &nodes, view: anyView, context: context, overrides: anyView.dd.sessionReplayPrivacyOverrides) return nodes } @@ -26,7 +26,8 @@ internal struct ViewTreeRecorder { private func recordRecursively( nodes: inout [Node], view: UIView, - context: ViewTreeRecordingContext + context: ViewTreeRecordingContext, + overrides: PrivacyOverrides ) { 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 = SessionReplayPrivacyOverrides.merge(subview.dd.sessionReplayPrivacyOverrides, 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: PrivacyOverrides) -> 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 938b765d84..17e9410480 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -123,13 +123,16 @@ 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 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) { + init(frameInRootView: CGRect, view: UIView, overrides: SessionReplayPrivacyOverrides) { self.frame = frameInRootView self.backgroundColor = view.backgroundColor?.cgColor.safeCast self.layerBorderColor = view.layer.borderColor?.safeCast @@ -138,6 +141,21 @@ extension ViewAttributes { self.alpha = view.alpha self.isHidden = view.isHidden self.intrinsicContentSize = view.intrinsicContentSize + 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/SessionReplay+objc.swift b/DatadogSessionReplay/Sources/SessionReplay+objc.swift index 0286475a8f..794e327b96 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,14 +25,28 @@ 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) } + + /// 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. -@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. @@ -50,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) } } @@ -58,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) } } @@ -66,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) } } @@ -74,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) } } @@ -97,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, @@ -127,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 @@ -143,7 +160,6 @@ public enum DDSessionReplayConfigurationPrivacyLevel: Int { case .allow: return .allow case .mask: return .mask case .maskUserInput: return .maskUserInput - default: return .mask } } @@ -157,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 @@ -173,7 +190,6 @@ public enum DDTextAndInputPrivacyLevel: Int { case .maskSensitiveInputs: return .maskSensitiveInputs case .maskAllInputs: return .maskAllInputs case .maskAll: return .maskAll - default: return .maskAll } } @@ -187,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. @@ -213,9 +230,10 @@ public enum DDImagePrivacyLevel: Int { } } -/// Available privacy levels for content masking. -@objc -public enum DDTouchPrivacyLevel: Int { +/// Available privacy levels for touch masking. +@objc(DDTouchPrivacyLevel) +@_spi(objc) +public enum objc_TouchPrivacyLevel: Int { /// Show all touches. case show @@ -226,7 +244,6 @@ public enum DDTouchPrivacyLevel: Int { switch self { case .show: return .show case .hide: return .hide - default: return .hide } } @@ -237,5 +254,4 @@ public enum DDTouchPrivacyLevel: Int { } } } - #endif diff --git a/DatadogSessionReplay/Sources/SessionReplay.swift b/DatadogSessionReplay/Sources/SessionReplay.swift index 38551d3dc6..df662f6ab5 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 !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." + ) + } + guard configuration.replaySampleRate > 0 else { return } diff --git a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift index b6f85be7ee..e821c867e4 100644 --- a/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift +++ b/DatadogSessionReplay/Sources/SessionReplayConfiguration.swift @@ -77,12 +77,12 @@ 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( - 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/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 new file mode 100644 index 0000000000..6e4b949a2a --- /dev/null +++ b/DatadogSessionReplay/Sources/SessionReplayPrivacyOverrides.swift @@ -0,0 +1,115 @@ +/* + * 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 +import DatadogInternal + +// MARK: - DatadogExtension for 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.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskNone`. + public var sessionReplayPrivacyOverrides: SessionReplayPrivacyOverrides { + return SessionReplayPrivacyOverrides(self.type) + } +} + +// MARK: - Associated Keys + +private var associatedTextAndInputPrivacyKey: UInt8 = 3 +private var associatedImagePrivacyKey: UInt8 = 4 +private var associatedTouchPrivacyKey: UInt8 = 5 +private var associatedHiddenPrivacyKey: UInt8 = 6 + +// MARK: - SessionReplayPrivacyOverrides + +/// `UIView` extension to manage the Session Replay privacy override settings. +public final class SessionReplayPrivacyOverrides { + internal let view: UIView + + public init(_ view: UIView) { + 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, &associatedTextAndInputPrivacyKey) as? TextAndInputPrivacyLevel + } + set { + 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, &associatedImagePrivacyKey) as? ImagePrivacyLevel + } + set { + 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, &associatedTouchPrivacyKey) as? TouchPrivacyLevel + } + set { + 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 hide: Bool? { + get { + return objc_getAssociatedObject(view, &associatedHiddenPrivacyKey) as? Bool + } + set { + objc_setAssociatedObject(view, &associatedHiddenPrivacyKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + } +} + +// MARK: - Equatable +extension PrivacyOverrides: Equatable { + public static func == (lhs: SessionReplayPrivacyOverrides, rhs: SessionReplayPrivacyOverrides) -> Bool { + return lhs.view === rhs.view + && lhs.textAndInputPrivacy == rhs.textAndInputPrivacy + && lhs.imagePrivacy == rhs.imagePrivacy + && lhs.touchPrivacy == rhs.touchPrivacy + && lhs.hide == rhs.hide + } +} + +// 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. + internal static func merge(_ child: PrivacyOverrides, with parent: PrivacyOverrides) -> PrivacyOverrides { + 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 PrivacyOverrides = SessionReplayPrivacyOverrides +#endif diff --git a/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.swift new file mode 100644 index 0000000000..b91cfe65b2 --- /dev/null +++ b/DatadogSessionReplay/Sources/UIView+SessionReplayPrivacyOverrides+objc.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 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 { _swift.hide.map { NSNumber(value: $0) } } + set { _swift.hide = newValue?.boolValue } + } +} + +#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/DDSessionReplayOverridesTests.swift b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift new file mode 100644 index 0000000000..9ed8a59bb7 --- /dev/null +++ b/DatadogSessionReplay/Tests/DDSessionReplayOverridesTests.swift @@ -0,0 +1,204 @@ +/* + * 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 +@_spi(objc) +@testable import DatadogSessionReplay + +class DDSessionReplayOverrideTests: XCTestCase { + // MARK: Privacy Overrides Interoperability + func testTextAndInputPrivacyLevelsOverrideInterop() { + 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(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(objc_TouchPrivacyLevelOverride.show._swift, .show) + XCTAssertEqual(objc_TouchPrivacyLevelOverride.hide._swift, .hide) + XCTAssertNil(objc_TouchPrivacyLevelOverride.none._swift) + + XCTAssertEqual(objc_TouchPrivacyLevelOverride(.show), .show) + XCTAssertEqual(objc_TouchPrivacyLevelOverride(.hide), .hide) + XCTAssertEqual(objc_TouchPrivacyLevelOverride(nil), .none) + } + + func testHidePrivacyLevelsOverrideInterop() { + // Testing Swift -> Objective-C interaction + let view = UIView() + let objcOverrides = view.ddSessionReplayPrivacyOverrides + + // Set via Swift + view.dd.sessionReplayPrivacyOverrides.hide = true + XCTAssertEqual(objcOverrides.hide, NSNumber(value: true)) + + view.dd.sessionReplayPrivacyOverrides.hide = false + XCTAssertEqual(objcOverrides.hide, NSNumber(value: false)) + + view.dd.sessionReplayPrivacyOverrides.hide = nil + XCTAssertNil(objcOverrides.hide) + + // Set via Objective-C + objcOverrides.hide = NSNumber(value: true) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, true) + + objcOverrides.hide = NSNumber(value: false) + XCTAssertEqual(view.dd.sessionReplayPrivacyOverrides.hide, false) + + objcOverrides.hide = nil + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) + } + + // MARK: Setting Privacy Overrides + func testSettingAndClearingObjectOverridesInObjc() { + // Given + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomBoolean() + + // When + let overrides = objc_SessionReplayPrivacyOverrides(view: UIView()) + overrides.textAndInputPrivacy = textAndInputPrivacy + overrides.imagePrivacy = imagePrivacy + overrides.touchPrivacy = touchPrivacy + overrides.hide = hidePrivacy + + // Then + XCTAssertEqual(overrides.textAndInputPrivacy, textAndInputPrivacy) + XCTAssertEqual(overrides.imagePrivacy, imagePrivacy) + XCTAssertEqual(overrides.touchPrivacy, touchPrivacy) + XCTAssertEqual(overrides.hide, hidePrivacy) + + // When + overrides.textAndInputPrivacy = .none + overrides.imagePrivacy = .none + overrides.touchPrivacy = .none + overrides.hide = false + + // Then + XCTAssertEqual(overrides.textAndInputPrivacy, .none) + XCTAssertEqual(overrides.imagePrivacy, .none) + XCTAssertEqual(overrides.touchPrivacy, .none) + XCTAssertEqual(overrides.hide, false) + } + + func testSettingAndClearingViewOverridesInObjc() { + // Given + let view = UIView() + let textAndInputPrivacy: objc_TextAndInputPrivacyLevelOverride = .mockRandom() + let imagePrivacy: objc_ImagePrivacyLevelOverride = .mockRandom() + let touchPrivacy: objc_TouchPrivacyLevelOverride = .mockRandom() + let hidePrivacy = NSNumber.mockRandomBoolean() + + // 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.mockRandomBoolean() + + // 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.mockRandomBoolean() + + // 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.mockRandomBoolean() + } + + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.textAndInputPrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.imagePrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.touchPrivacy) + XCTAssertNil(view?.ddSessionReplayPrivacyOverrides.hide) + } +} +#endif 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..9517fa0cbd 100644 --- a/DatadogCore/Tests/DatadogObjc/DDSessionReplayTests.swift +++ b/DatadogSessionReplay/Tests/DDSessionReplayTests.swift @@ -5,11 +5,10 @@ */ #if os(iOS) - import XCTest import TestUtilities import DatadogInternal - +@_spi(objc) @testable import DatadogSessionReplay class DDSessionReplayTests: XCTestCase { @@ -18,7 +17,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 +30,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 +54,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 +81,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 +108,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 +151,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 +170,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 +183,7 @@ class DDSessionReplayTests: XCTestCase { ) // When - DDSessionReplay.enable(with: config) + objc_SessionReplay.enable(with: config) // Then let sr = try XCTUnwrap(core.get(feature: SessionReplayFeature.self)) diff --git a/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift new file mode 100644 index 0000000000..c8cb566900 --- /dev/null +++ b/DatadogSessionReplay/Tests/Mocks/PrivacyOverridesMock+objc.swift @@ -0,0 +1,49 @@ +/* + * 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 mockRandomBoolean() -> NSNumber? { + NSNumber(value: [true, false].randomElement()!) + } +} +#endif diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 3260fb7c8f..1649624184 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() ) } @@ -77,7 +78,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: CGFloat = .mockAny(), alpha: CGFloat = .mockAny(), isHidden: Bool = .mockAny(), - intrinsicContentSize: CGSize = .mockAny() + intrinsicContentSize: CGSize = .mockAny(), + overrides: PrivacyOverrides = .mockAny() ) -> ViewAttributes { return .init( frame: frame, @@ -87,7 +89,8 @@ extension ViewAttributes: AnyMockable, RandomMockable { layerCornerRadius: layerCornerRadius, alpha: alpha, isHidden: isHidden, - intrinsicContentSize: intrinsicContentSize + intrinsicContentSize: intrinsicContentSize, + overrides: overrides ) } @@ -171,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: @@ -429,7 +433,8 @@ extension TouchSnapshot.Touch: AnyMockable, RandomMockable { id: .mockRandom(), phase: [.down, .move, .up].randomElement()!, date: .mockRandom(), - position: .mockRandom() + position: .mockRandom(), + touchOverride: nil ) } @@ -443,7 +448,8 @@ extension TouchSnapshot.Touch: AnyMockable, RandomMockable { id: id, phase: phase, date: date, - position: position + position: position, + touchOverride: nil ) } } @@ -527,7 +533,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, @@ -577,4 +583,33 @@ internal extension Optional where Wrapped == NodeSemantics { return try XCTUnwrap(builders.first, file: file, line: line) } } + +extension PrivacyOverrides: AnyMockable, RandomMockable { + public static func mockAny() -> PrivacyOverrides { + return mockWith() + } + + public static func mockRandom() -> PrivacyOverrides { + return mockWith( + textAndInputPrivacy: .mockRandom(), + imagePrivacy: .mockRandom(), + touchPrivacy: .mockRandom(), + hide: .mockRandom() + ) + } + + public static func mockWith( + textAndInputPrivacy: TextAndInputPrivacyLevel? = nil, + imagePrivacy: ImagePrivacyLevel? = nil, + touchPrivacy: TouchPrivacyLevel? = nil, + hide: Bool? = nil + ) -> PrivacyOverrides { + let override = PrivacyOverrides(UIView.mockRandom()) + override.textAndInputPrivacy = textAndInputPrivacy + override.imagePrivacy = imagePrivacy + override.touchPrivacy = touchPrivacy + override.hide = hide + return override + } +} #endif 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()! } } diff --git a/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift b/DatadogSessionReplay/Tests/Mocks/UIKitMocks.swift index 2607b80e52..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 { @@ -116,19 +122,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/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 5c282bd317..5c948f0710 100644 --- a/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/TouchSnapshotProducer/WindowTouchSnapshotProducerTests.swift @@ -117,5 +117,132 @@ class WindowTouchSnapshotProducerTests: XCTestCase { // Then XCTAssertGreaterThan(snapshot1!.date, Date()) } + + // MARK: - Touch Override View 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 + ) + + // 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.sessionReplayPrivacyOverrides.touchPrivacy = touchOverride + + let childView = UIView(frame: .mockRandom()) + parentView.addSubview(childView) + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: childView) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver + ) + + // 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.sessionReplayPrivacyOverrides.touchPrivacy = .hide + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver + ) + + // 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.sessionReplayPrivacyOverrides.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 + ) + + // 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.sessionReplayPrivacyOverrides.touchPrivacy = .show + + let touch = UITouchMock(phase: .began, location: .mockRandom(), view: view) + let touchEvent = UITouchEventMock(touches: [touch]) + + let producer = WindowTouchSnapshotProducer( + windowObserver: mockWindowObserver + ) + + // 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.sessionReplayPrivacyOverrides.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 + ) + + // 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 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/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UIImageViewRecorderTests.swift index 134ce11250..411562ac6d 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: PrivacyOverrides = .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 0ffa2e0ccf..814802b420 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.overrides = .mockWith(hide: 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/ViewTreeRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecorderTests.swift index 2bf792b14c..5dd34b2ad6 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.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.hide = true + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + parentView.dd.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.imagePrivacy = childImagePrivacy + let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.dd.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.imagePrivacy = childImagePrivacy + let parentImagePrivacy: ImagePrivacyLevel = .mockRandom() + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.dd.sessionReplayPrivacyOverrides.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 4eb7fe862e..e4b4ae96ed 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: PrivacyOverrides = .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,6 +181,75 @@ class ViewAttributesTests: XCTestCase { XCTAssertEqual(attributes.alpha, float) XCTAssertEqual(attributes.isHidden, boolean) XCTAssertEqual(attributes.intrinsicContentSize, rect.size) + XCTAssertEqual(attributes.overrides, overrides) + } + + // MARK: Privacy Overrides + + func testItDefaultsToNilWhenNoOverrideIsSet() { + // Given + let view: UIView = .mockAny() + + // When + let attributes = createViewAttributes(with: view) + + // Then + XCTAssertNil(attributes.overrides.textAndInputPrivacy) + XCTAssertNil(attributes.overrides.imagePrivacy) + XCTAssertNil(attributes.overrides.touchPrivacy) + XCTAssertNil(attributes.overrides.hide) + } + + func testChildViewInheritsParentHideOverride() { + // Given + let childView = UIView.mock(withFixture: .visible(.someAppearance)) + let parentView = UIView.mock(withFixture: .visible(.someAppearance)) + parentView.addSubview(childView) + parentView.dd.sessionReplayPrivacyOverrides.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.sessionReplayPrivacyOverrides.hide = true + childView.dd.sessionReplayPrivacyOverrides.hide = false + + let recorder = ViewTreeRecorder(nodeRecorders: createDefaultNodeRecorders()) + + // When + 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: PrivacyOverrides = .mockRandom() + 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()) + + // Then + XCTAssertEqual(nodes.count, 2) } } // swiftlint:enable opening_brace @@ -218,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/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 diff --git a/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift new file mode 100644 index 0000000000..b33b02a791 --- /dev/null +++ b/DatadogSessionReplay/Tests/SessionReplayOverrideTests.swift @@ -0,0 +1,194 @@ +/* + * 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 +@_spi(Internal) +@testable import DatadogSessionReplay + +class SessionReplayPrivacyOverridesTests: XCTestCase { + // MARK: Setting overrides + func testWhenNoOverrideIsSet_itDefaultsToNil() { + // Given + let view = UIView() + + // Then + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.imagePrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.touchPrivacy) + XCTAssertNil(view.dd.sessionReplayPrivacyOverrides.hide) + } + + func testWithOverrides() { + // Given + let view = UIView() + + // When + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide + view.dd.sessionReplayPrivacyOverrides.hide = true + + // Then + 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.sessionReplayPrivacyOverrides.textAndInputPrivacy = .maskAllInputs + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = .maskAll + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = .hide + view.dd.sessionReplayPrivacyOverrides.hide = true + + // When + view.dd.sessionReplayPrivacyOverrides.textAndInputPrivacy = nil + view.dd.sessionReplayPrivacyOverrides.imagePrivacy = nil + view.dd.sessionReplayPrivacyOverrides.touchPrivacy = nil + view.dd.sessionReplayPrivacyOverrides.hide = nil + + // Then + 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 + 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: PrivacyOverrides = .mockRandom() + + 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: PrivacyOverrides = .mockAny() + parentOverrides.imagePrivacy = overrides.imagePrivacy + parentOverrides.touchPrivacy = overrides.touchPrivacy + + // When + let merged = SessionReplayPrivacyOverrides.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: PrivacyOverrides = .mockRandom() + let parentOverrides: PrivacyOverrides = .mockAny() + + // When + let merged = SessionReplayPrivacyOverrides.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: 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) + + // 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: PrivacyOverrides = .mockRandom() + childOverrides.hide = false + let parentOverrides: PrivacyOverrides = .mockRandom() + parentOverrides.hide = true + + // When + let merged = SessionReplayPrivacyOverrides.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 diff --git a/DatadogSessionReplay/Tests/SessionReplayTests.swift b/DatadogSessionReplay/Tests/SessionReplayTests.swift index c3a36e33cd..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) } @@ -52,6 +54,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 { 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 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/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 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" 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 diff --git a/api-surface-objc b/api-surface-objc index 42e2742185..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]) @@ -980,9 +989,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 +1891,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 +1910,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..ad45de4623 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 @@ -2038,17 +2159,34 @@ 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 DDSessionReplayConfiguration: NSObject +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 objc_SessionReplayConfiguration: NSObject @objc public var replaySampleRate: Float - @objc public var defaultPrivacyLevel: DDSessionReplayConfigurationPrivacyLevel + @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: 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 objc_TextAndInputPrivacyLevel: Int + case maskSensitiveInputs + case maskAllInputs + case maskAll +public enum objc_ImagePrivacyLevel: Int + case maskNonBundledOnly + case maskAll + case maskNone +public enum objc_TouchPrivacyLevel: 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]) diff --git a/docs/sdk_performance.md b/docs/sdk_performance.md new file mode 100644 index 0000000000..f23f729303 --- /dev/null +++ b/docs/sdk_performance.md @@ -0,0 +1,204 @@ +# SDK Performance and impact on the host application + +## Methodology + +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 initialized; + +- running app with SDK initialized and data collection enabled (`trackingConsent: .granted`); + +- running app with SDK initialized 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 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 initialized” - SDK is installed, but not initialized. + +- “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 initialized and data collection enabled” to record network traffic with Charles Proxy. + +- 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 the 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 4 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`. 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 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