Skip to content

Commit

Permalink
feat: filter automatic screenview tracking events (#367)
Browse files Browse the repository at this point in the history
  • Loading branch information
levibostian committed Aug 30, 2023
1 parent f7c456a commit 1a535f9
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 22 deletions.
Expand Up @@ -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
15 changes: 15 additions & 0 deletions Sources/Common/Store/SdkConfig.swift
@@ -1,4 +1,7 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif

/**
Configuration options for the Customer.io SDK.
Expand Down Expand Up @@ -29,6 +32,7 @@ public struct SdkConfig {
backgroundQueueExpiredSeconds: Seconds.secondsFromDays(3),
logLevel: CioLogLevel.error,
autoTrackScreenViews: false,
filterAutoScreenViewEvents: nil,
autoTrackDeviceAttributes: true
)
}
Expand Down Expand Up @@ -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
Expand Down
75 changes: 55 additions & 20 deletions Sources/Tracking/CustomerIOImplementation+ScreenViews.swift
@@ -1,3 +1,4 @@
import CioInternalCommon
import Foundation
#if canImport(UIKit)
import UIKit
Expand All @@ -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)
Expand All @@ -46,36 +79,38 @@ 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()
}
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
}

/**
Expand Down
24 changes: 24 additions & 0 deletions 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.
}
}
8 changes: 6 additions & 2 deletions Tests/Shared/IntegrationTest.swift
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions 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 }
}
}
5 changes: 5 additions & 0 deletions Tests/Tracking/APITest.swift
Expand Up @@ -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
}
}
}

Expand Down
94 changes: 94 additions & 0 deletions 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)
}
}

0 comments on commit 1a535f9

Please sign in to comment.