diff --git a/PostHog/Autocapture/AutocaptureEventProcessing.swift b/PostHog/Autocapture/AutocaptureEventProcessing.swift index 29cd8650e..ef394a93c 100644 --- a/PostHog/Autocapture/AutocaptureEventProcessing.swift +++ b/PostHog/Autocapture/AutocaptureEventProcessing.swift @@ -13,9 +13,8 @@ } class PostHogAutocaptureEventProcessor: AutocaptureEventProcessing { - private static let viewHierarchyDelimiter = ";" - - private unowned var postHogInstance: PostHogSDK + private weak var postHogInstance: PostHogSDK? + private var debounceTimers: [Int: Timer] = [:] init(postHogInstance: PostHogSDK) { self.postHogInstance = postHogInstance @@ -26,8 +25,47 @@ PostHogAutocaptureIntegration.removeEventProcessor(self) } + /** + Processes an autocapture event, with optional debounce logic for controls that emit frequent events. + + - Parameters: + - source: The source of the event (e.g., gesture recognizer, action method, or notification). + - event: The autocapture event data, containing properties, screen name, and other metadata. + + If the event has a `debounceInterval` greater than 0, the event is debounced. + This is useful for UIControls like `UISlider` that emit frequent value changes, ensuring only the last value is captured. + The debounce interval is defined per UIControl by the `ph_autocaptureDebounceInterval` property of `AutoCapturable` + */ func process(source: PostHogAutocaptureIntegration.EventData.EventSource, event: PostHogAutocaptureIntegration.EventData) { - + assert(Thread.isMainThread, "Event captured off main thread") + + let eventHash = event.hashValue + // debounce frequent UIControl events (e.g., UISlider) to reduce event noise + if event.debounceInterval > 0 { + debounceTimers[eventHash]?.invalidate() // Keep cancelling existing + debounceTimers[eventHash] = Timer.scheduledTimer(withTimeInterval: event.debounceInterval, repeats: false) { [weak self] _ in + self?.handleEventProcessing(source: source, event: event) + self?.debounceTimers.removeValue(forKey: eventHash) // Clean up once fired + } + } else { + handleEventProcessing(source: source, event: event) + } + } + + private static let viewHierarchyDelimiter = ";" + /** + Handles the processing of autocapture events by extracting event details, building properties, and sending them to PostHog. + + - Parameters: + - source: The source of the event, which could be an action method, gesture recognizer, or notification. Associated values are already mapped to `$event_type` earlier in the chain + - event: The event data including view hierarchy, screen name, and other metadata. + + This function extracts event details such as the event type, view hierarchy, and touch coordinates. + It creates a structured payload with relevant properties (e.g., tag_name, elements, element_chain) and sends it to the associated PostHog instance for further processing. + */ + private func handleEventProcessing(source: PostHogAutocaptureIntegration.EventData.EventSource, event: PostHogAutocaptureIntegration.EventData) { + guard let postHogInstance else { return } + let eventType: String = switch source { case let .actionMethod(description): description case let .gestureRecognizer(description): description @@ -59,8 +97,6 @@ properties["$touch_x"] = coordinates.x properties["$touch_y"] = coordinates.y } - - hedgeLog("autocaptured \"\(eventType)\" in \(elements.first!.description) with \(properties) ") postHogInstance.autocapture( eventType: eventType, diff --git a/PostHog/Autocapture/PostHogAutocaptureIntegration.swift b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift index 8b8bfde3d..9fb8164ba 100644 --- a/PostHog/Autocapture/PostHogAutocaptureIntegration.swift +++ b/PostHog/Autocapture/PostHogAutocaptureIntegration.swift @@ -13,8 +13,8 @@ // TODO: Rage Clicks - possible? // TODO: Dead Clicks - possible? class PostHogAutocaptureIntegration { - struct EventData { - struct ViewNode: CustomStringConvertible { + struct EventData: Hashable { + struct ViewNode: CustomStringConvertible, Hashable { let text: String let targetClass: String let index: Int @@ -40,6 +40,12 @@ let targetClass: String let accessibilityLabel: String? let accessibilityIdentifier: String? + // values >0 means that this event will be debounced for `debounceInterval` + let debounceInterval: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(viewHierarchy.map(\.targetClass)) + } } // static -> won't be added twice @@ -166,7 +172,8 @@ .map { $1.viewNode(index: $0) }, targetClass: descriptiveTypeName, accessibilityLabel: accessibilityLabel, - accessibilityIdentifier: accessibilityIdentifier + accessibilityIdentifier: accessibilityIdentifier, + debounceInterval: ph_autocaptureDebounceInterval ) } } @@ -287,12 +294,14 @@ protocol AutoCapturable { var ph_autocaptureText: String? { get } var ph_autocaptureEvents: UIControl.Event { get } + var ph_autocaptureDebounceInterval: TimeInterval { get } func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool } extension UIView: AutoCapturable { @objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside } @objc var ph_autocaptureText: String? { nil } + @objc var ph_autocaptureDebounceInterval: TimeInterval { 0 } @objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool { false // by default views are not tracked. Can be overriden in subclasses } @@ -341,7 +350,9 @@ } extension UISlider { - override var ph_autocaptureEvents: UIControl.Event { .touchUpInside } + override var ph_autocaptureDebounceInterval: TimeInterval { 0.3 } + override var ph_autocaptureEvents: UIControl.Event { .valueChanged } + override var ph_autocaptureText: String? { "\(value)" } } #if !os(tvOS)