From 8b1406fc7e2bb378bc9d5facaa469596406ad21c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 23 Oct 2024 10:19:49 +0300 Subject: [PATCH] feat(autocapture): process captured events --- .../AutocaptureEventProcessing.swift | 73 +++++++ .../PostHogAutocaptureIntegration.swift | 195 +++++++++++------- PostHog/Models/PostHogEvent.swift | 2 +- PostHog/PostHogConfig.swift | 3 + PostHog/PostHogSDK.swift | 48 ++++- PostHog/UIViewController.swift | 16 +- 6 files changed, 252 insertions(+), 85 deletions(-) create mode 100644 PostHog/Autocapture/AutocaptureEventProcessing.swift diff --git a/PostHog/Autocapture/AutocaptureEventProcessing.swift b/PostHog/Autocapture/AutocaptureEventProcessing.swift new file mode 100644 index 000000000..29cd8650e --- /dev/null +++ b/PostHog/Autocapture/AutocaptureEventProcessing.swift @@ -0,0 +1,73 @@ +// +// AutocaptureEventProcessing.swift +// PostHog +// +// Created by Yiannis Josephides on 22/10/2024. +// + +#if os(iOS) || targetEnvironment(macCatalyst) + import UIKit + + protocol AutocaptureEventProcessing: AnyObject { + func process(source: PostHogAutocaptureIntegration.EventData.EventSource, event: PostHogAutocaptureIntegration.EventData) + } + + class PostHogAutocaptureEventProcessor: AutocaptureEventProcessing { + private static let viewHierarchyDelimiter = ";" + + private unowned var postHogInstance: PostHogSDK + + init(postHogInstance: PostHogSDK) { + self.postHogInstance = postHogInstance + PostHogAutocaptureIntegration.addEventProcessor(self) + } + + deinit { + PostHogAutocaptureIntegration.removeEventProcessor(self) + } + + func process(source: PostHogAutocaptureIntegration.EventData.EventSource, event: PostHogAutocaptureIntegration.EventData) { + + let eventType: String = switch source { + case let .actionMethod(description): description + case let .gestureRecognizer(description): description + case let .notification(name): name + } + + var properties: [String: Any] = [:] + + if let screenName = event.screenName { + properties["$screen_name"] = event.screenName + } + + let elements = event.viewHierarchy.map { node in + [ + "text": node.text, + "tag_name": node.targetClass, // required + "order": node.index, + "attributes": [ // required + "attr__class": node.targetClass + ] + ] + } + + let elementsChain = event.viewHierarchy + .map(\.description) + .joined(separator: Self.viewHierarchyDelimiter) + + if let coordinates = event.touchCoordinates { + properties["$touch_x"] = coordinates.x + properties["$touch_y"] = coordinates.y + } + + hedgeLog("autocaptured \"\(eventType)\" in \(elements.first!.description) with \(properties) ") + + postHogInstance.autocapture( + eventType: eventType, + elements: elements, + elementsChain: elementsChain, + properties: properties + ) + } + } +#endif diff --git a/PostHog/Autocapture/PostHogAutocaptureIntegration.swift b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift index cc6540518..8b8bfde3d 100644 --- a/PostHog/Autocapture/PostHogAutocaptureIntegration.swift +++ b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift @@ -14,23 +14,34 @@ // TODO: Dead Clicks - possible? class PostHogAutocaptureIntegration { struct EventData { - enum Source { - case notification - case actionMethod - case gestureRecognizer + struct ViewNode: CustomStringConvertible { + let text: String + let targetClass: String + let index: Int + let subviewCount: Int + + // Note: For some reason text will not be processed if not present in elements_chain string. + // Couldn't pinpoint to exact place `posthog` where we do this + var description: String { + "\(targetClass)\(text.isEmpty ? "" : ":text=\"\(text)\"")" + } + } + + enum EventSource { + case notification(name: String) + case actionMethod(description: String) + case gestureRecognizer(description: String) } + let touchCoordinates: CGPoint? + let value: String? let screenName: String? + let viewHierarchy: [ViewNode] + let targetClass: String let accessibilityLabel: String? let accessibilityIdentifier: String? - let targetViewClass: String - let targetText: String? - let hierarchy: String - let touchCoordinates: CGPoint } - let config: PostHogConfig - // static -> won't be added twice private static let addNotificationObservers: Void = { NotificationCenter.default.addObserver(PostHogAutocaptureIntegration.self, selector: #selector(didEndEditing), name: UITextField.textDidEndEditingNotification, object: nil) @@ -52,46 +63,40 @@ ) }() - init(_ config: PostHogConfig) { - self.config = config - Self.setupSwizzlingOnce - Self.addNotificationObservers + // TODO: Account for multiple instances/processors + private(set) weak static var eventProcessor: (any AutocaptureEventProcessing)? + + static func addEventProcessor(_ processor: some AutocaptureEventProcessing) { + if eventProcessor == nil { + setupSwizzlingOnce + addNotificationObservers + } + eventProcessor = processor + } + + static func removeEventProcessor(_: some AutocaptureEventProcessing) { + eventProcessor = nil } // `UITextField` or `UITextView` did end editing notification @objc static func didEndEditing(_ notification: NSNotification) { guard let view = notification.object as? UIView else { return } - let source: EventData.Source = .notification - // Text fields in SwiftUI are identifiable only after the text field is edited. - print("PostHogSDK.shared.capture source: \(source) \(getCaptureDescription(for: view, eventDescription: "didEndEditing")))") + eventProcessor?.process(source: .notification(name: "change"), event: view.eventData) } } - private func getCaptureDescription(for element: UIView, eventDescription: String) -> String { - var description = "" - - if let targetText = element.eventData.targetText { - description = "\"\(targetText)\"" - } else if let vcName = element.nearestViewController?.descriptiveTypeName { - description = "in \(vcName)" - } - - return "\(eventDescription) \(element.descriptiveTypeName) \(description)".trimmingCharacters(in: .whitespacesAndNewlines) - } - extension UIApplication { @objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool { defer { - // TODO: Reduce SwiftUI noise by finding the unique view that the action method is attached to. // Currently, the action methods pointing to a SwiftUI target are blocked. let targetClass = String(describing: object_getClassName(target)) if targetClass.contains("SwiftUI") { print("PostHogSDK.shared.capture SwiftUI -> \(targetClass)") } else if let control = sender as? UIControl, control.ph_shouldTrack(action, for: target), - let eventDescription = control.event(for: action, to: target)?.description + let eventDescription = control.event(for: action, to: target)?.description(forControl: control) { - print("PostHogSDK.shared.capture \(getCaptureDescription(for: control, eventDescription: eventDescription))") + PostHogAutocaptureIntegration.eventProcessor?.process(source: .actionMethod(description: eventDescription), event: control.eventData) } } @@ -119,50 +124,60 @@ #endif } - let gestureAction: String? + let gestureDescription: String? switch self { case is UITapGestureRecognizer: - gestureAction = "tap" + gestureDescription = EventType.kTouch case is UISwipeGestureRecognizer: - gestureAction = "swipe" + gestureDescription = EventType.kSwipe case is UIPanGestureRecognizer: - gestureAction = "pan" + gestureDescription = EventType.kPan case is UILongPressGestureRecognizer: - gestureAction = "longPress" + gestureDescription = EventType.kLongPress #if !os(tvOS) case is UIPinchGestureRecognizer: - gestureAction = "pinch" + gestureDescription = EventType.kPinch case is UIRotationGestureRecognizer: - gestureAction = "rotation" + gestureDescription = EventType.kRotation case is UIScreenEdgePanGestureRecognizer: - gestureAction = "screenEdgePan" + gestureDescription = EventType.kPan #endif default: - gestureAction = nil + gestureDescription = nil } - guard let gestureAction else { return } + guard let gestureDescription else { return } - print("PostHogSDK.shared.capture -> \(gestureAction) \(descriptiveTypeName) -> \(view.eventData)") + PostHogAutocaptureIntegration.eventProcessor?.process(source: .gestureRecognizer(description: gestureDescription), event: view.eventData) } } extension UIView { - private static let viewHierarchyDelimiter = " → " - var eventData: PostHogAutocaptureIntegration.EventData { PostHogAutocaptureIntegration.EventData( + touchCoordinates: nil, + value: ph_autocaptureText + .map(sanitizeText), screenName: nearestViewController .flatMap(UIViewController.ph_topViewController) .flatMap(UIViewController.getViewControllerName), + viewHierarchy: sequence(first: self, next: \.superview) + .enumerated() + .map { $1.viewNode(index: $0) }, + targetClass: descriptiveTypeName, accessibilityLabel: accessibilityLabel, - accessibilityIdentifier: accessibilityIdentifier, - targetViewClass: descriptiveTypeName, - targetText: sanitizeTitle(ph_autocaptureTitle), - hierarchy: sequence(first: self, next: \.superview) - .map(\.descriptiveTypeName) - .joined(separator: UIView.viewHierarchyDelimiter), - touchCoordinates: CGPoint.zero // TODO: + accessibilityIdentifier: accessibilityIdentifier + ) + } + } + + extension UIView { + func viewNode(index: Int) -> PostHogAutocaptureIntegration.EventData.ViewNode { + PostHogAutocaptureIntegration.EventData.ViewNode( + text: ph_autocaptureText.map(sanitizeText) ?? "", + targetClass: descriptiveTypeName, + index: index, + subviewCount: subviews.count ) } } @@ -199,20 +214,36 @@ } extension UIControl.Event { - var description: String? { - if self == .touchUpInside { - return "tap" - } else if UIControl.Event.allTouchEvents.contains(self) { - return "touch" + func description(forControl control: UIControl) -> String? { + if self == .primaryActionTriggered { + if control is UIButton { + return EventType.kTouch // UIButton triggers primaryAction with a touch interaction + } else if control is UISegmentedControl { + return EventType.kValueChange // UISegmentedControl changes its value + } else if control is UITextField { + return EventType.kSubmit // UITextField uses this for submit-like behavior + } else if control is UISwitch { + return EventType.kToggle + } else if control is UIDatePicker { + return EventType.kValueChange + } else if control is UIStepper { + return EventType.kValueChange + } else { + return EventType.kPrimaryAction + } + } + + // General event descriptions + if UIControl.Event.allTouchEvents.contains(self) { + return EventType.kTouch } else if UIControl.Event.allEditingEvents.contains(self) { - return "edit" + return EventType.kChange } else if self == .valueChanged { - return "valueChange" - } else if self == .primaryActionTriggered { - return "primaryAction" + return EventType.kValueChange } else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered { - return "menuAction" + return EventType.kMenuAction } + return nil } } @@ -254,21 +285,21 @@ } protocol AutoCapturable { - var ph_autocaptureTitle: String? { get } + var ph_autocaptureText: String? { get } var ph_autocaptureEvents: UIControl.Event { get } func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool } extension UIView: AutoCapturable { @objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside } - @objc var ph_autocaptureTitle: String? { nil } + @objc var ph_autocaptureText: String? { nil } @objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool { - false // by default views are not tracked. Can be overwritten in subclasses + false // by default views are not tracked. Can be overriden in subclasses } } extension UIButton { - override var ph_autocaptureTitle: String? { title(for: .normal) ?? title(for: .selected) } + override var ph_autocaptureText: String? { title(for: .normal) ?? title(for: .selected) } } extension UIControl { @@ -279,7 +310,7 @@ extension UISegmentedControl { override var ph_autocaptureEvents: UIControl.Event { .valueChanged } - override var ph_autocaptureTitle: String? { titleForSegment(at: selectedSegmentIndex) } + override var ph_autocaptureText: String? { titleForSegment(at: selectedSegmentIndex) } } extension UIPageControl { @@ -297,15 +328,20 @@ } extension UITextField { - override var ph_autocaptureTitle: String? { text ?? attributedText?.string ?? placeholder } + override var ph_autocaptureText: String? { text ?? attributedText?.string ?? placeholder } } extension UITextView { - override var ph_autocaptureTitle: String? { text ?? attributedText?.string } + override var ph_autocaptureText: String? { text ?? attributedText?.string } } extension UIStepper { override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + override var ph_autocaptureText: String? { "\(value)" } + } + + extension UISlider { + override var ph_autocaptureEvents: UIControl.Event { .touchUpInside } } #if !os(tvOS) @@ -314,9 +350,24 @@ } #endif - private func sanitizeTitle(_ title: String?) -> String? { - guard let title else { return nil } - return title.replacingOccurrences(of: "[^a-zA-Z0-9]+", with: "-", options: .regularExpression, range: nil) + private func sanitizeText(_ title: String) -> String { + title.replacingOccurrences(of: "[^a-zA-Z0-9.]+", with: "-", options: .regularExpression, range: nil) + } + + enum EventType { + static let kValueChange = "value_changed" + static let kSubmit = "submit" + static let kToggle = "toggle" + static let kPrimaryAction = "primary_action" + static let kMenuAction = "menu_action" + static let kChange = "change" + + static let kTouch = "touch" + static let kSwipe = "swipe" + static let kPinch = "pinch" + static let kPan = "pan" + static let kRotation = "rotation" + static let kLongPress = "long_press" } #endif diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index 2ed7bd611..88b25cc43 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -85,7 +85,7 @@ public class PostHogEvent { "uuid": uuid.uuidString, ] - if let apiKey = apiKey { + if let apiKey { json["api_key"] = apiKey } diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d3d0b8e41..f72be1f5f 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -24,6 +24,9 @@ import Foundation @objc public var preloadFeatureFlags: Bool = true @objc public var captureApplicationLifecycleEvents: Bool = true @objc public var captureScreenViews: Bool = true + @objc public var captureTouches: Bool = true + @objc public var captureGestures: Bool = true + @objc public var captureEdits: Bool = true @objc public var debug: Bool = false @objc public var optOut: Bool = false @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index a020804c2..75670b13c 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -49,8 +49,10 @@ let maxRetryDelay = 30.0 #if os(iOS) private var replayIntegration: PostHogReplayIntegration? #endif - private var autocaptureIntegration: PostHogAutocaptureIntegration? + #if os(iOS) || targetEnvironment(macCatalyst) + private var autocaptureEventProcessor: PostHogAutocaptureEventProcessor? + #endif // nonisolated(unsafe) is introduced in Swift 5.10 #if swift(>=5.10) @objc public nonisolated(unsafe) static let shared: PostHogSDK = { @@ -103,7 +105,7 @@ let maxRetryDelay = 30.0 #if os(iOS) replayIntegration = PostHogReplayIntegration(config) #endif - autocaptureIntegration = PostHogAutocaptureIntegration(config) + autocaptureEventProcessor = PostHogAutocaptureEventProcessor(postHogInstance: self) #if !os(watchOS) do { reachability = try Reachability() @@ -460,7 +462,7 @@ let maxRetryDelay = 30.0 let properties = buildProperties(distinctId: distinctId, properties: [ "distinct_id": distinctId, - "$anon_distinct_id": oldDistinctId, + "$anon_distinct_id": oldDistinctId ], userProperties: sanitizeDicionary(userProperties), userPropertiesSetOnce: sanitizeDicionary(userPropertiesSetOnce)) let sanitizedProperties = sanitizeProperties(properties) @@ -597,7 +599,7 @@ let maxRetryDelay = 30.0 } let props = [ - "$screen_name": screenTitle, + "$screen_name": screenTitle ].merging(sanitizeDicionary(properties) ?? [:]) { prop, _ in prop } let distinctId = getDistinctId() @@ -612,6 +614,42 @@ let maxRetryDelay = 30.0 )) } + func autocapture( + eventType: String, + elements: [[String: Any]], + elementsChain: String, + properties: [String: Any] + ) { + if !isEnabled() { + return + } + + if isOptOutState() { + return + } + + guard let queue else { + return + } + + let props = [ + "$event_type": eventType, + "$elements": elements, + "$elements_chain": elementsChain, + ].merging(sanitizeDicionary(properties) ?? [:]) { prop, _ in prop } + + let distinctId = getDistinctId() + + let properties = buildProperties(distinctId: distinctId, properties: props) + let sanitizedProperties = sanitizeProperties(properties) + + queue.add(PostHogEvent( + event: "$autocapture", + distinctId: distinctId, + properties: sanitizedProperties + )) + } + private func sanitizeProperties(_ properties: [String: Any]) -> [String: Any] { if let sanitizer = config.propertiesSanitizer { return sanitizer.sanitize(properties) @@ -818,7 +856,7 @@ let maxRetryDelay = 30.0 if !flagCallReported.contains(flagKey) { let properties: [String: Any] = [ "$feature_flag": flagKey, - "$feature_flag_response": flagValue ?? "", + "$feature_flag_response": flagValue ?? "" ] flagCallReported.insert(flagKey) diff --git a/PostHog/UIViewController.swift b/PostHog/UIViewController.swift index 5de4062ec..c62c20952 100644 --- a/PostHog/UIViewController.swift +++ b/PostHog/UIViewController.swift @@ -43,13 +43,15 @@ } static func getViewControllerName(_ viewController: UIViewController) -> String? { - var title: String? = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "") - - if title?.isEmpty == true { - title = viewController.title ?? nil + let viewControllerClass = String(describing: type(of: viewController)) + if let title = viewController.title ?? + viewController.navigationItem.title ?? + (viewController.navigationItem.titleView as? UILabel)?.text ?? + (viewController.navigationItem.titleView as? UILabel)?.attributedText?.string + { + return "\(title) (\(viewControllerClass))" } - - return title + return viewControllerClass.replacingOccurrences(of: "ViewController", with: "") } private func captureScreenView(_ window: UIWindow?) { @@ -61,7 +63,7 @@ let name = UIViewController.getViewControllerName(top) - if let name = name { + if let name { PostHogSDK.shared.screen(name) } }