From 1a535f96761fbeb1a31a7d0820cab77ec4e12c5f Mon Sep 17 00:00:00 2001 From: Levi Bostian Date: Wed, 30 Aug 2023 14:34:27 -0500 Subject: [PATCH] feat: filter automatic screenview tracking events (#367) --- ...Extensions.swift => UIKitExtensions.swift} | 6 ++ Sources/Common/Store/SdkConfig.swift | 15 +++ ...CustomerIOImplementation+ScreenViews.swift | 75 +++++++++++---- .../Extensions/UIKitExtensionsTests.swift | 24 +++++ Tests/Shared/IntegrationTest.swift | 8 +- .../extension/QueueStorageExtension.swift | 9 ++ Tests/Tracking/APITest.swift | 5 + ...omerIOImplementation+ScreenViewsTest.swift | 94 +++++++++++++++++++ 8 files changed, 214 insertions(+), 22 deletions(-) rename Sources/Common/Extensions/{UIApplicationExtensions.swift => UIKitExtensions.swift} (52%) create mode 100644 Tests/Common/Extensions/UIKitExtensionsTests.swift create mode 100644 Tests/Shared/extension/QueueStorageExtension.swift create mode 100644 Tests/Tracking/CustomerIOImplementation+ScreenViewsTest.swift diff --git a/Sources/Common/Extensions/UIApplicationExtensions.swift b/Sources/Common/Extensions/UIKitExtensions.swift similarity index 52% rename from Sources/Common/Extensions/UIApplicationExtensions.swift rename to Sources/Common/Extensions/UIKitExtensions.swift index ff60efadb..32823afb4 100644 --- a/Sources/Common/Extensions/UIApplicationExtensions.swift +++ b/Sources/Common/Extensions/UIKitExtensions.swift @@ -10,4 +10,10 @@ public extension UIApplication { } } +public extension UIViewController { + // find the bundleId of the framework this View belongs in. This can be used to differentiate Views that belong to host app and Views that belong to SDKs/frameworks. + var bundleIdOfView: String? { + Bundle(for: type(of: self)).bundleIdentifier + } +} #endif diff --git a/Sources/Common/Store/SdkConfig.swift b/Sources/Common/Store/SdkConfig.swift index 121b8d052..350c8a1ff 100644 --- a/Sources/Common/Store/SdkConfig.swift +++ b/Sources/Common/Store/SdkConfig.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(UIKit) +import UIKit +#endif /** Configuration options for the Customer.io SDK. @@ -29,6 +32,7 @@ public struct SdkConfig { backgroundQueueExpiredSeconds: Seconds.secondsFromDays(3), logLevel: CioLogLevel.error, autoTrackScreenViews: false, + filterAutoScreenViewEvents: nil, autoTrackDeviceAttributes: true ) } @@ -144,6 +148,17 @@ public struct SdkConfig { */ public var autoTrackScreenViews: Bool + #if canImport(UIKit) + /** + Filter automatic screenview events to remove events that are irrelevant to your app. + + Return `true` from function if you would like the screenview event to be tracked. + + Default: `nil`, which uses the default filter function packaged by the SDK. Provide a non-nil value to not call the SDK's filtering. + */ + public var filterAutoScreenViewEvents: ((UIViewController) -> Bool)? + #endif + /** Handler to be called by our automatic screen tracker to generate `screen` event body variables. You can use this to override our defaults and pass custom values in the body of the `screen` event diff --git a/Sources/Tracking/CustomerIOImplementation+ScreenViews.swift b/Sources/Tracking/CustomerIOImplementation+ScreenViews.swift index 1548750fb..e0b682725 100644 --- a/Sources/Tracking/CustomerIOImplementation+ScreenViews.swift +++ b/Sources/Tracking/CustomerIOImplementation+ScreenViews.swift @@ -1,3 +1,4 @@ +import CioInternalCommon import Foundation #if canImport(UIKit) import UIKit @@ -24,18 +25,50 @@ extension CustomerIO { guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) } + + func performScreenTracking(onViewController viewController: UIViewController) { + guard let diGraph = diGraph else { + return // SDK not initialized yet. Therefore, we ignore event. + } + + guard let name = viewController.getNameForAutomaticScreenViewTracking() else { + diGraph.logger.info("Automatic screenview tracking event ignored for \(viewController). Could not determine name to use for screen.") + return + } + + // Before we track event, apply a filter to remove events that could be unhelpful. + let customerOverridenFilter = diGraph.sdkConfig.filterAutoScreenViewEvents + let defaultSdkFilter: (UIViewController) -> Bool = { viewController in + let isViewFromApple = viewController.bundleIdOfView?.hasPrefix("com.apple") ?? false + + if isViewFromApple { + return false // filter out events that come from Apple's frameworks. We consider those irrelevant for customers. + } + + // Views from customer's app or 3rd party SDKs are considered relevant and are tracked. + return true + } + + let filter = customerOverridenFilter ?? defaultSdkFilter + let shouldTrackEvent = filter(viewController) + + guard shouldTrackEvent else { + let isUsingSdkDefaultFilter = customerOverridenFilter == nil + diGraph.logger.debug("automatic screenview ignored for, \(name):\(viewController.bundleIdOfView ?? ""). It was filtered out. Is using sdk default filter: \(isUsingSdkDefaultFilter)") + return // event has been filtered out. Ignore it. + } + + let addionalScreenViewData = CustomerIOImplementation.autoScreenViewBody?() ?? [:] + automaticScreenView(name: name, data: addionalScreenViewData) + } } // screen view tracking is not available for notification service extension. disable all functions having to deal with // screen view tracking feature. @available(iOSApplicationExtension, unavailable) extension UIViewController { - var defaultScreenViewBody: ScreenViewData { - ScreenViewData() - } - @objc func cio_swizzled_UIKit_viewDidAppear(_ animated: Bool) { - performScreenTracking() + performAutomaticScreenTracking() // this function looks like recursion, but it's how you call ViewController.viewDidAppear. cio_swizzled_UIKit_viewDidAppear(animated) @@ -46,10 +79,10 @@ extension UIViewController { // this function looks like recursion, but it's how you call ViewController.viewDidDisappear. cio_swizzled_UIKit_viewDidDisappear(animated) - performScreenTracking() + performAutomaticScreenTracking() } - func performScreenTracking() { + func performAutomaticScreenTracking() { var rootViewController = viewIfLoaded?.window?.rootViewController if rootViewController == nil { rootViewController = getActiveRootViewController() @@ -57,25 +90,27 @@ extension UIViewController { guard let viewController = getVisibleViewController(fromRootViewController: rootViewController) else { return } - let nameOfViewControllerClass = String(describing: type(of: viewController)) - var name = nameOfViewControllerClass.replacingOccurrences( + + CustomerIO.shared.performScreenTracking(onViewController: viewController) + } + + func getNameForAutomaticScreenViewTracking() -> String? { + let nameOfViewControllerClass = String(describing: type(of: self)) + + let name = nameOfViewControllerClass.replacingOccurrences( of: "ViewController", with: "", options: .caseInsensitive ) - if name.isEmpty || name == "" { - if let title = viewController.title { - name = title - } else { - // XXX: we couldn't infer a name, we should log it for debug purposes - return - } + if !name.isEmpty { + return name } - guard let data = CustomerIOImplementation.autoScreenViewBody?() else { - CustomerIO.shared.automaticScreenView(name: name, data: defaultScreenViewBody) - return + + if title != nil { + return title } - CustomerIO.shared.automaticScreenView(name: name, data: data) + + return nil } /** diff --git a/Tests/Common/Extensions/UIKitExtensionsTests.swift b/Tests/Common/Extensions/UIKitExtensionsTests.swift new file mode 100644 index 000000000..6b3774a99 --- /dev/null +++ b/Tests/Common/Extensions/UIKitExtensionsTests.swift @@ -0,0 +1,24 @@ +@testable import CioTracking +import Foundation +import SharedTests +import SwiftUI +import UIKit +import XCTest + +class UIKitExtensionsTest: UnitTest { + // MARK: bundleIdOfView + + func test_bundleIdOfView_givenSwiftUIView_expectAppleBundleId() { + XCTAssertEqual(SwiftUI.UIHostingController(rootView: Text("")).bundleIdOfView, "com.apple.SwiftUI") + } + + func test_bundleIdOfView_givenUIKitView_expectAppleBundleId() { + XCTAssertEqual(UIAlertController().bundleIdOfView, "com.apple.UIKitCore") + } + + func test_bundleIdOfView_givenViewFromHostApp_expectHostAppBundleId() { + class MyViewController: UIViewController {} + + XCTAssertEqual(MyViewController().bundleIdOfView, "CommonTests") // CommonTests is value because the ViewController class above exists in the Tests target named CommonTests. + } +} diff --git a/Tests/Shared/IntegrationTest.swift b/Tests/Shared/IntegrationTest.swift index 4a4983584..3bd5a4ef5 100644 --- a/Tests/Shared/IntegrationTest.swift +++ b/Tests/Shared/IntegrationTest.swift @@ -16,7 +16,11 @@ open class IntegrationTest: UnitTest { public private(set) var sampleDataFilesUtil: SampleDataFilesUtil! override open func setUp() { - super.setUp() + setUp(modifySdkConfig: nil) + } + + open func setUp(modifySdkConfig: ((inout SdkConfig) -> Void)? = nil) { + super.setUp(modifySdkConfig: modifySdkConfig) sampleDataFilesUtil = SampleDataFilesUtil(fileStore: diGraph.fileStorage) @@ -42,7 +46,7 @@ open class IntegrationTest: UnitTest { // This class initializes the SDK by default in setUp() for test function convenience because most test functions will need the SDK initialized. // For the test functions that need to test SDK initialization, this function exists to be called by test function. public func uninitializeSDK(file: StaticString = #file, line: UInt = #line) { - tearDown() + CustomerIO.resetSharedInstance() // confirm that the SDK did get uninitialized XCTAssertNil(CustomerIO.shared.siteId, file: file, line: line) diff --git a/Tests/Shared/extension/QueueStorageExtension.swift b/Tests/Shared/extension/QueueStorageExtension.swift new file mode 100644 index 000000000..ee94754dc --- /dev/null +++ b/Tests/Shared/extension/QueueStorageExtension.swift @@ -0,0 +1,9 @@ +@testable import CioInternalCommon +@testable import CioTracking +import Foundation + +public extension QueueStorage { + func filterTrackEvents(_ type: CioTracking.QueueTaskType) -> [QueueTaskMetadata] { + getInventory().filter { $0.taskType == type.rawValue } + } +} diff --git a/Tests/Tracking/APITest.swift b/Tests/Tracking/APITest.swift index 2a5054f75..b4afa5c51 100644 --- a/Tests/Tracking/APITest.swift +++ b/Tests/Tracking/APITest.swift @@ -115,6 +115,11 @@ class TrackingAPITest: UnitTest { config.logLevel = .error config.autoTrackPushEvents = false config.autoScreenViewBody = { [:] } + config.filterAutoScreenViewEvents = { viewController in + class MyViewController: UIViewController {} + + return viewController is MyViewController + } } } diff --git a/Tests/Tracking/CustomerIOImplementation+ScreenViewsTest.swift b/Tests/Tracking/CustomerIOImplementation+ScreenViewsTest.swift new file mode 100644 index 000000000..76b0f3ff7 --- /dev/null +++ b/Tests/Tracking/CustomerIOImplementation+ScreenViewsTest.swift @@ -0,0 +1,94 @@ +@testable import CioInternalCommon +@testable import CioTracking +import Foundation +import SharedTests +import SwiftUI +import UIKit +import XCTest + +class CustomerIOImplementationScreenViewsTest: IntegrationTest { + override func setUp() { + super.setUp() + + // Screenview events are ignored if no profile identified + CustomerIO.shared.identify(identifier: String.random) + } + + // MARK: performScreenTracking + + func test_performScreenTracking_givenCustomerProvidesFilter_expectSdkDefaultFilterNotUsed() { + var customerProvidedFilterCalled = false + setUp(modifySdkConfig: { config in + config.filterAutoScreenViewEvents = { _ in + customerProvidedFilterCalled = true + + return true + } + }) + + CustomerIO.shared.performScreenTracking(onViewController: UIAlertController()) + + XCTAssertTrue(customerProvidedFilterCalled) + assertEventTracked() + } + + // SwiftUI wraps UIKit views and displays them in your app. Therefore, there is a good chance that automatic screenview tracking for a SwiftUI app will try to track screenview events from Views belonging to the SwiftUI framework or UIKit framework. Our SDK, by default, filters those events out. + func test_performScreenTracking_givenViewFromSwiftUI_expectFalse() { + CustomerIO.shared.performScreenTracking(onViewController: SwiftUI.UIHostingController(rootView: Text(""))) + + assertNoEventTracked() + } + + // Our SDK believes that UIKit framework views are irrelevant to tracking data for customers. Our SDK, by default, filters those events out. + func test_performScreenTracking_givenViewFromUIKit_expectFalse() { + CustomerIO.shared.performScreenTracking(onViewController: UIAlertController()) + + assertNoEventTracked() + } + + func test_performScreenTracking_givenViewFromHostApp_expectTrue() { + class ViewInsideOfHostApp: UIViewController {} + + CustomerIO.shared.performScreenTracking(onViewController: ViewInsideOfHostApp()) + + assertEventTracked() + } + + // MARK: getNameForAutomaticScreenViewTracking + + func test_getNameForAutomaticScreenViewTracking_givenViewWithNoTitle_expectNil() { + class ViewController: UIViewController {} + + let view = ViewController() + view.title = nil + + XCTAssertNil(view.getNameForAutomaticScreenViewTracking()) + } + + func test_getNameForAutomaticScreenViewTracking_givenViewWithTooBasicName_expectNil() { + class ViewController: UIViewController {} + let view = ViewController() + + XCTAssertNil(view.getNameForAutomaticScreenViewTracking()) + } + + func test_getNameForAutomaticScreenViewTracking_givenView_expectCleanupName() { + class LoginViewController: UIViewController {} + + let view = LoginViewController() + + XCTAssertEqual(view.getNameForAutomaticScreenViewTracking(), "Login") + } +} + +extension CustomerIOImplementationScreenViewsTest { + private func assertNoEventTracked() { + XCTAssertTrue(diGraph.queueStorage.filterTrackEvents(.trackEvent).isEmpty) + } + + private func assertEventTracked(numberOfEventsAdded: Int = 1) { + let screenviewEvents = diGraph.queueStorage.filterTrackEvents(.trackEvent) + + XCTAssertEqual(screenviewEvents.count, numberOfEventsAdded) + } +}