Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add user interactions autocapture #186

Closed
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably a relic from including a local Amplitude-Swift in another project - please do not check this in.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do.

"pins" : [
{
"identity" : "analytics-connector-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/amplitude/analytics-connector-ios.git",
"state" : {
"revision" : "e2ca17ac735bcbc48b13062484541702ef45153d",
"version" : "1.0.3"
}
},
{
"identity" : "experiment-ios-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/amplitude/experiment-ios-client",
"state" : {
"revision" : "6a94d70915b3756daae2a989fa57f03c19547c67",
"version" : "1.13.5"
}
}
],
"version" : 2
}
6 changes: 6 additions & 0 deletions Sources/Amplitude/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public struct Constants {
static let AMP_APPLICATION_BACKGROUNDED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Application Backgrounded"
static let AMP_DEEP_LINK_OPENED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Deep Link Opened"
static let AMP_SCREEN_VIEWED_EVENT = "\(AMP_AMPLITUDE_PREFIX)Screen Viewed"
static let AMP_USER_INTERACTION_EVENT = "\(AMP_AMPLITUDE_PREFIX)User Interaction"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we change the name to be "User Interacted" to follow the pattern of other default tracking event names?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can discuss with Alan to align on the naming.

static let AMP_REVENUE_EVENT = "revenue_amount"

static let AMP_APP_VERSION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Version"
Expand All @@ -94,6 +95,11 @@ public struct Constants {
static let AMP_APP_LINK_REFERRER_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Link Referrer"
static let AMP_APP_SCREEN_NAME_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Screen Name"

static let AMP_INTERACTION_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Interaction"
static let AMP_ELEMENT_LABEL_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Label"
static let AMP_ELEMENT_VALUE_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Value"
static let AMP_ELEMENT_TYPE_PROPERTY = "\(AMP_AMPLITUDE_PREFIX)Element Type"

public struct Configuration {
public static let FLUSH_QUEUE_SIZE = 30
public static let FLUSH_INTERVAL_MILLIS = 30 * 1000 // 30s
Expand Down
12 changes: 12 additions & 0 deletions Sources/Amplitude/DefaultTrackingOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class DefaultTrackingOptions {
public var sessions: Bool = true
public var appLifecycles: Bool
public var screenViews: Bool
public var userInteractions: Bool

public init(
sessions: Bool = true,
Expand All @@ -20,5 +21,16 @@ public class DefaultTrackingOptions {
self.sessions = sessions
self.appLifecycles = appLifecycles
self.screenViews = screenViews
self.userInteractions = false
}

public convenience init (
sessions: Bool = true,
appLifecycles: Bool = false,
screenViews: Bool = false,
userInteractions: Bool = false
) {
self.init(sessions: sessions, appLifecycles: appLifecycles, screenViews: screenViews)
self.userInteractions = userInteractions
Comment on lines +27 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you are adding a new convenience initializer vs just adding userInteractions to the default initializer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was to bypass an error related to the new parameter of the original initializer. I'll look into a different way to fix that.

}
}
60 changes: 60 additions & 0 deletions Sources/Amplitude/Events/UserInteractionEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import UIKit

public class UserInteractionEvent: BaseEvent {

public enum InteractionValue {
case tap(dead: Bool = false)
case longPress(dead: Bool = false)
case rageTap
case focusGained
case focusLost(didTextFieldChange: Bool = false)
case sliderChanged(to: Int)

var description: String {
switch self {
case .tap(let dead): return dead ? "Dead Tapped" : "Tapped"
case .longPress(let dead): return dead ? "Dead Long Pressed" : "Long Pressed"
case .rageTap: return "Rage Tapped"
case .focusGained: return "Focus Gained"
case .focusLost(let didTextFieldChange):
return didTextFieldChange ? "Focus Lost After Text Modification" : "Focus Lost"
case .sliderChanged(let percentage):
return "Value Changed To \(percentage)%"
}
}
}

convenience init(_ interactionValue: InteractionValue, label: String? = nil, value: String? = nil, type: UIAccessibilityTraits = .none) {
self.init(eventType: Constants.AMP_USER_INTERACTION_EVENT, eventProperties: [
Constants.AMP_INTERACTION_PROPERTY: interactionValue.description,
Constants.AMP_ELEMENT_LABEL_PROPERTY: label,
Constants.AMP_ELEMENT_VALUE_PROPERTY: value,
Constants.AMP_ELEMENT_TYPE_PROPERTY: type.stringify()
])
}
}

extension UIAccessibilityTraits {
func stringify() -> String? {
var strings = [String]()
if contains(.adjustable) { strings.append("Adjustable") }
if contains(.allowsDirectInteraction) { strings.append("Allows Direct Interaction") }
if contains(.button) { strings.append("Button") }
if contains(.causesPageTurn) { strings.append("Causes Page Turn") }
if contains(.header) { strings.append("Header") }
if contains(.image) { strings.append("Image") }
if contains(.keyboardKey) { strings.append("Keyboard Key") }
if contains(.link) { strings.append("Link") }
if contains(.notEnabled) { strings.append("Not Enabled") }
if contains(.playsSound) { strings.append("Plays Sound") }
if contains(.searchField) { strings.append("Search Field") }
if contains(.selected) { strings.append("Selected") }
if contains(.startsMediaSession) { strings.append("Starts Media Session") }
if contains(.staticText) { strings.append("Static Text") }
if contains(.summaryElement) { strings.append("Summary Element") }
if contains(.tabBar) { strings.append("Tab Bar") }
if contains(.updatesFrequently) { strings.append("Updates Frequently") }

return strings.isEmpty ? nil : strings.joined(separator: ", ")
}
}
Comment on lines +37 to +60
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not a lot here that's informative in any sort of analytical sense, we should not collect this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that some of them are unnecessary, but traits like button, link, tab, and search field can provide semantics info about the element that was clicked. Is that something that we would want to capture?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, in many cases, this data will be used to help uniquely identify an auto captured event - something like "signup button clicked".
So, if we have more specific indicators like an accessibility label, I'm not sure this is useful to capture. If information is a lot more sparse and this would be a useful indicator in telling one event from the other, then the above approach is probably the way to go.

216 changes: 111 additions & 105 deletions Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,131 +7,137 @@

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the changes to this file are to correct the indentation.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check in any style related changes separately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do.

#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)

import Foundation
import SwiftUI

class IOSLifecycleMonitor: UtilityPlugin {
private var application: UIApplication?
private var appNotifications: [NSNotification.Name] = [
UIApplication.didEnterBackgroundNotification,
UIApplication.willEnterForegroundNotification,
UIApplication.didFinishLaunchingNotification,
UIApplication.didBecomeActiveNotification,
]
private var utils: DefaultEventUtils?
private var sendApplicationOpenedOnDidBecomeActive = false

override init() {
// TODO: Check if lifecycle plugin works for app extension
// App extensions can't use UIApplication.shared, so
// funnel it through something to check; Could be nil.
application = UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication
super.init()
setupListeners()
import Foundation
import SwiftUI

class IOSLifecycleMonitor: UtilityPlugin {
private var application: UIApplication? {
// TODO: Check if lifecycle plugin works for app extension
// App extensions can't use UIApplication.shared, so
// funnel it through something to check; Could be nil.
UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication
}
Comment on lines +14 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapped the logic to get the UIApplication.shared in a computed property since UIApplication.shared is not available during the app startup.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? That should not be the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried accessing the application instance later when the app starts and with the previous code, the instance is always nil.


private var appNotifications: [NSNotification.Name] = [
UIApplication.didEnterBackgroundNotification,
UIApplication.willEnterForegroundNotification,
UIApplication.didFinishLaunchingNotification,
UIApplication.didBecomeActiveNotification,
]
private var utils: DefaultEventUtils?
private var sendApplicationOpenedOnDidBecomeActive = false
private var userInteractionCaptureDelegate: UserInteractionCaptureDelegate?

override init() {
super.init()
setupListeners()
}

public override func setup(amplitude: Amplitude) {
super.setup(amplitude: amplitude)
utils = DefaultEventUtils(amplitude: amplitude)
if amplitude.configuration.defaultTracking.screenViews {
UIKitScreenViews.register(amplitude)
}
if amplitude.configuration.defaultTracking.userInteractions {
userInteractionCaptureDelegate = UserInteractionCaptureDelegate(amplitude, with: { [weak self] in self?.application })
}
}

public override func setup(amplitude: Amplitude) {
super.setup(amplitude: amplitude)
utils = DefaultEventUtils(amplitude: amplitude)
if amplitude.configuration.defaultTracking.screenViews {
UIKitScreenViews.register(amplitude)
}
@objc
func notificationResponse(notification: Notification) {
switch notification.name {
case UIApplication.didEnterBackgroundNotification:
didEnterBackground(notification: notification)
case UIApplication.willEnterForegroundNotification:
applicationWillEnterForeground(notification: notification)
case UIApplication.didFinishLaunchingNotification:
applicationDidFinishLaunchingNotification(notification: notification)
case UIApplication.didBecomeActiveNotification:
applicationDidBecomeActive(notification: notification)
default:
break
}
}

@objc
func notificationResponse(notification: Notification) {
switch notification.name {
case UIApplication.didEnterBackgroundNotification:
didEnterBackground(notification: notification)
case UIApplication.willEnterForegroundNotification:
applicationWillEnterForeground(notification: notification)
case UIApplication.didFinishLaunchingNotification:
applicationDidFinishLaunchingNotification(notification: notification)
case UIApplication.didBecomeActiveNotification:
applicationDidBecomeActive(notification: notification)
default:
break
}
func setupListeners() {
// Configure the current life cycle events
let notificationCenter = NotificationCenter.default
for notification in appNotifications {
notificationCenter.addObserver(
self,
selector: #selector(notificationResponse(notification:)),
name: notification,
object: application
)
}

func setupListeners() {
// Configure the current life cycle events
let notificationCenter = NotificationCenter.default
for notification in appNotifications {
notificationCenter.addObserver(
self,
selector: #selector(notificationResponse(notification:)),
name: notification,
object: application
)
}
}

}
func applicationWillEnterForeground(notification: Notification) {
let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)

func applicationWillEnterForeground(notification: Notification) {
let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)

let fromBackground: Bool
if let sharedApplication = application {
switch sharedApplication.applicationState {
case .active, .inactive:
fromBackground = false
case .background:
fromBackground = true
@unknown default:
fromBackground = false
}
} else {
let fromBackground: Bool
if let sharedApplication = application {
switch sharedApplication.applicationState {
case .active, .inactive:
fromBackground = false
case .background:
fromBackground = true
@unknown default:
fromBackground = false
}

amplitude?.onEnterForeground(timestamp: timestamp)
sendApplicationOpened(fromBackground: fromBackground)
} else {
fromBackground = false
}

func didEnterBackground(notification: Notification) {
let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
self.amplitude?.onExitForeground(timestamp: timestamp)
if self.amplitude?.configuration.defaultTracking.appLifecycles == true {
self.amplitude?.track(eventType: Constants.AMP_APPLICATION_BACKGROUNDED_EVENT)
}
amplitude?.onEnterForeground(timestamp: timestamp)
sendApplicationOpened(fromBackground: fromBackground)
}

func didEnterBackground(notification: Notification) {
let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
self.amplitude?.onExitForeground(timestamp: timestamp)
if self.amplitude?.configuration.defaultTracking.appLifecycles == true {
self.amplitude?.track(eventType: Constants.AMP_APPLICATION_BACKGROUNDED_EVENT)
}
}

func applicationDidFinishLaunchingNotification(notification: Notification) {
utils?.trackAppUpdatedInstalledEvent()
func applicationDidFinishLaunchingNotification(notification: Notification) {
utils?.trackAppUpdatedInstalledEvent()

// Pre SceneDelegate apps wil not fire a willEnterForeground notification on app launch.
// Instead, use the initial applicationDidBecomeActive
let usesSceneDelegate = application?.delegate?.responds(to: #selector(UIApplicationDelegate.application(_:configurationForConnecting:options:))) ?? false
if !usesSceneDelegate {
sendApplicationOpenedOnDidBecomeActive = true
}
// Pre SceneDelegate apps wil not fire a willEnterForeground notification on app launch.
// Instead, use the initial applicationDidBecomeActive
let usesSceneDelegate = application?.delegate?.responds(to: #selector(UIApplicationDelegate.application(_:configurationForConnecting:options:))) ?? false
if !usesSceneDelegate {
sendApplicationOpenedOnDidBecomeActive = true
}
}

func applicationDidBecomeActive(notification: Notification) {
guard sendApplicationOpenedOnDidBecomeActive else {
return
}
sendApplicationOpenedOnDidBecomeActive = false

let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
amplitude?.onEnterForeground(timestamp: timestamp)
sendApplicationOpened(fromBackground: false)
func applicationDidBecomeActive(notification: Notification) {
guard sendApplicationOpenedOnDidBecomeActive else {
return
}
sendApplicationOpenedOnDidBecomeActive = false

private func sendApplicationOpened(fromBackground: Bool) {
guard amplitude?.configuration.defaultTracking.appLifecycles ?? false else {
return
}
let info = Bundle.main.infoDictionary
let currentBuild = info?["CFBundleVersion"] as? String
let currentVersion = info?["CFBundleShortVersionString"] as? String
self.amplitude?.track(eventType: Constants.AMP_APPLICATION_OPENED_EVENT, eventProperties: [
Constants.AMP_APP_BUILD_PROPERTY: currentBuild ?? "",
Constants.AMP_APP_VERSION_PROPERTY: currentVersion ?? "",
Constants.AMP_APP_FROM_BACKGROUND_PROPERTY: fromBackground,
])
let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000)
amplitude?.onEnterForeground(timestamp: timestamp)
sendApplicationOpened(fromBackground: false)
}

private func sendApplicationOpened(fromBackground: Bool) {
guard amplitude?.configuration.defaultTracking.appLifecycles ?? false else {
return
}
let info = Bundle.main.infoDictionary
let currentBuild = info?["CFBundleVersion"] as? String
let currentVersion = info?["CFBundleShortVersionString"] as? String
self.amplitude?.track(eventType: Constants.AMP_APPLICATION_OPENED_EVENT, eventProperties: [
Constants.AMP_APP_BUILD_PROPERTY: currentBuild ?? "",
Constants.AMP_APP_VERSION_PROPERTY: currentVersion ?? "",
Constants.AMP_APP_FROM_BACKGROUND_PROPERTY: fromBackground,
])
}
}

#endif
Loading
Loading