From ad5600946cecec798a70d708e5e833779c6d3f6b Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 1 Sep 2025 17:07:34 -0600 Subject: [PATCH] feat: add tap handler, send span --- Package.swift | 2 +- Sources/Client/InstrumentationManager.swift | 24 +++++++++++ Sources/Client/ObservabilityClient.swift | 3 +- .../SemanticConventionLD.swift | 0 Sources/Client/TapHandler.swift | 40 +++++++++++++++++++ Sources/Client/TouchEvent.swift | 20 ++++++++++ Sources/Client/UIWindowSendEvent.swift | 38 ++++++++++++++++++ Sources/Common/CGFloat+ToString.swift | 7 ++++ .../LaunchDarklyCrashFilter.swift | 3 ++ 9 files changed, 135 insertions(+), 2 deletions(-) rename Sources/{Common => Client}/SemanticConventionLD.swift (100%) create mode 100644 Sources/Client/TapHandler.swift create mode 100644 Sources/Client/TouchEvent.swift create mode 100644 Sources/Client/UIWindowSendEvent.swift create mode 100644 Sources/Common/CGFloat+ToString.swift diff --git a/Package.swift b/Package.swift index 51cdf3c..2bc36d3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 5.9 import PackageDescription let package = Package( diff --git a/Sources/Client/InstrumentationManager.swift b/Sources/Client/InstrumentationManager.swift index fbbcf50..13b79e1 100644 --- a/Sources/Client/InstrumentationManager.swift +++ b/Sources/Client/InstrumentationManager.swift @@ -4,6 +4,8 @@ import OpenTelemetrySdk import StdoutExporter import OpenTelemetryProtocolExporterHttp +import UIKit.UIWindow + import API import Interfaces import Common @@ -26,6 +28,8 @@ public final class InstrumentationManager { private var cachedLongCounters = AtomicDictionary() private var cachedHistograms = AtomicDictionary() private var cachedUpDownCounters = AtomicDictionary() + private let lock: NSLock = NSLock() + private let handler = TapHandler() public init(sdkKey: String, options: Options, sessionManager: SessionManager) { self.sdkKey = sdkKey @@ -143,8 +147,28 @@ public final class InstrumentationManager { self.otelMeter = OpenTelemetry.instance.meterProvider.get( name: options.serviceName ) + + self.install() } + private func install() { + lock.lock() + defer { lock.unlock() } + UIWindowSendEvent.inject { [weak self] uiWindow, uiEvent in + self?.handler.handle(event: uiEvent, window: uiWindow) { touchEvent in + var attributes = [String: AttributeValue]() + attributes["screen.name"] = .string(touchEvent.viewName) + attributes["target.id"] = .string(touchEvent.accessibilityIdentifier ?? touchEvent.viewName) + // sending location in points (since it is preferred over pixels) + attributes["position.x"] = .string(touchEvent.locationInPoints.x.toString()) + attributes["position.y"] = .string(touchEvent.locationInPoints.y.toString()) + self?.startSpan(name: "user.tap", attributes: attributes).end() + } + } + } + + // MARK: - Instrumentation + func recordMetric(metric: Metric) { var gauge = cachedGauges[metric.name] if gauge == nil { diff --git a/Sources/Client/ObservabilityClient.swift b/Sources/Client/ObservabilityClient.swift index d9951c7..ec7b2b1 100644 --- a/Sources/Client/ObservabilityClient.swift +++ b/Sources/Client/ObservabilityClient.swift @@ -16,7 +16,6 @@ public final class ObservabilityClient: Observe { private let options: Options private var cachedSpans = AtomicDictionary() - private var task: Task? private let crashReporter: CrashReporter private let urlSessionInstrumentation: URLSessionInstrumentation @@ -62,6 +61,8 @@ public final class ObservabilityClient: Observe { ) } + // MARK: - Instrumentation + public func recordMetric(metric: Metric) { instrumentationManager.recordMetric(metric: metric) } diff --git a/Sources/Common/SemanticConventionLD.swift b/Sources/Client/SemanticConventionLD.swift similarity index 100% rename from Sources/Common/SemanticConventionLD.swift rename to Sources/Client/SemanticConventionLD.swift diff --git a/Sources/Client/TapHandler.swift b/Sources/Client/TapHandler.swift new file mode 100644 index 0000000..81b2516 --- /dev/null +++ b/Sources/Client/TapHandler.swift @@ -0,0 +1,40 @@ +import UIKit.UIWindow +import Common + +public final class TapHandler { + private var startPoint: CGPoint? + + public init() {} + + public func handle(event: UIEvent, window: UIWindow, completion: (TouchEvent) -> Void) { + if let touches = event.allTouches, let touch = touches.first, let targetView = touch.view { + switch touch.phase { + case .began: + startPoint = touch.location(in: window) + case .ended: + if let startPoint { + let endPoint = touch.location(in: window) + let dx = endPoint.x - startPoint.x + let dy = endPoint.y - startPoint.y + if abs(dx) < 10 && abs(dy) < 10 { + let accessibilityIdentifier = targetView.accessibilityIdentifier + let targetClass = type(of: targetView) + + let viewName = accessibilityIdentifier ?? String(describing: targetClass) + + completion( + .init( + location: endPoint, + viewName: viewName, + accessibilityIdentifier: accessibilityIdentifier, + scale: targetView.window?.screen.scale ?? UIScreen.main.scale) + ) + } + } + startPoint = nil + default: + break + } + } + } +} diff --git a/Sources/Client/TouchEvent.swift b/Sources/Client/TouchEvent.swift new file mode 100644 index 0000000..2fb3119 --- /dev/null +++ b/Sources/Client/TouchEvent.swift @@ -0,0 +1,20 @@ +import UIKit + +public struct TouchEvent: Sendable, CustomStringConvertible { + public let location: CGPoint + public let viewName: String + public let accessibilityIdentifier: String? + public let scale: CGFloat + + public var locationInPoints: CGPoint { + return location + } + + public var locationInPixels: CGPoint { + return CGPoint(x: Int((location.x * scale).rounded()), y: Int((location.y * scale).rounded())) + } + + public var description: String { + return "TouchEvent(\(location), \(viewName), \(accessibilityIdentifier ?? "nil"), \(scale)) coordinates In pixels: \(locationInPixels.x), \(locationInPixels.y)" + } +} diff --git a/Sources/Client/UIWindowSendEvent.swift b/Sources/Client/UIWindowSendEvent.swift new file mode 100644 index 0000000..285207b --- /dev/null +++ b/Sources/Client/UIWindowSendEvent.swift @@ -0,0 +1,38 @@ +import UIKit.UIWindow + +public final class UIWindowSendEvent { + typealias SendEventRef = @convention(c) (UIWindow, Selector, UIEvent) -> Void + private static let sendEvenSelector = #selector(UIWindow.sendEvent(_:)) + + public static func inject( + into subclasses: [UIWindow.Type] = [], + block: @escaping (UIWindow, UIEvent) -> Void + ) { + guard let originalMethod = class_getInstanceMethod(UIWindow.self, sendEvenSelector) else { return } + + var originalIMP = Optional.none + + let swizzledSendEventBlock: @convention(block) (UIWindow, UIEvent) -> Void = { window, event in + if let originalIMP = originalIMP { + let castedIMP = unsafeBitCast(originalIMP, to: SendEventRef.self) + castedIMP(window, sendEvenSelector, event) + } + + guard !subclasses.isEmpty else { + return block(window, event) + } + if UIWindowSendEvent.shouldInject(into: window, subclasses: subclasses) { + block(window, event) + } + } + + let swizzledIMP = imp_implementationWithBlock(unsafeBitCast(swizzledSendEventBlock, to: AnyObject.self)) + originalIMP = method_setImplementation(originalMethod, swizzledIMP) + } + + private static func shouldInject(into receiver: Any, subclasses: [UIWindow.Type]) -> Bool { + let ids = subclasses.map { ObjectIdentifier($0) } + let receiverType = type(of: receiver) + return ids.contains(ObjectIdentifier(receiverType)) + } +} diff --git a/Sources/Common/CGFloat+ToString.swift b/Sources/Common/CGFloat+ToString.swift new file mode 100644 index 0000000..d0a9db6 --- /dev/null +++ b/Sources/Common/CGFloat+ToString.swift @@ -0,0 +1,7 @@ +import Foundation + +extension CGFloat { + public func toString() -> String { + String(format: "%.2f", self) + } +} diff --git a/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift b/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift index c5d981e..1a4768e 100644 --- a/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift +++ b/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift @@ -62,10 +62,13 @@ final class LaunchDarklyCrashFilter: NSObject, CrashReportFilter { let reportSections = crashReportString.components(separatedBy: "\\n") let incidentIdentifier = reportSections.first(where: { $0.contains(ReportSection.incidentIdentifier.rawValue) }) ?? "" let exceptionType = reportSections.first(where: { $0.contains(ReportSection.exceptionType.rawValue) }) ?? "" + let exceptionCodes = reportSections.first(where: { $0.contains(ReportSection.exceptionCodes.rawValue) }) ?? "" var attributes = [String: AttributeValue]() attributes[SemanticAttributes.exceptionType.rawValue] = .string(exceptionType.replacingOccurrences(of: "\"", with: "")) attributes[SemanticAttributes.exceptionStacktrace.rawValue] = .string(crashReportString) + attributes[SemanticAttributes.exceptionMessage.rawValue] = .string(exceptionCodes) + logger?.logRecordBuilder() .setAttributes(attributes) .setBody(.string(incidentIdentifier.replacingOccurrences(of: "\"", with: "")))