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 064dca1..0e5a8dc 100644 --- a/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift +++ b/Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift @@ -61,10 +61,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: ""))) diff --git a/Sources/Observability/InstrumentationManager.swift b/Sources/Observability/InstrumentationManager.swift index 2f21c37..67691fc 100644 --- a/Sources/Observability/InstrumentationManager.swift +++ b/Sources/Observability/InstrumentationManager.swift @@ -1,5 +1,7 @@ import Foundation +import UIKit.UIWindow + import OpenTelemetrySdk import OpenTelemetryApi import OpenTelemetryProtocolExporterHttp @@ -28,6 +30,8 @@ final class InstrumentationManager { private var cachedLongCounters = AtomicDictionary() private var cachedHistograms = AtomicDictionary() private var cachedUpDownCounters = AtomicDictionary() + private let lock: NSLock = NSLock() + private let handler = TapHandler() private let sampler: ExportSampler public init(sdkKey: String, options: Options, sessionManager: SessionManager) { @@ -172,8 +176,27 @@ final class InstrumentationManager { ) self.sampler = sampler + 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/Observability/ObservabilityClient.swift b/Sources/Observability/ObservabilityClient.swift index 6250f1d..1acde41 100644 --- a/Sources/Observability/ObservabilityClient.swift +++ b/Sources/Observability/ObservabilityClient.swift @@ -43,6 +43,8 @@ public final class ObservabilityClient: Observe { } } + // MARK: - Instrumentation + public func recordMetric(metric: Metric) { instrumentationManager.recordMetric(metric: metric) } diff --git a/Sources/Observability/TapHandler.swift b/Sources/Observability/TapHandler.swift new file mode 100644 index 0000000..81b2516 --- /dev/null +++ b/Sources/Observability/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/Observability/TouchEvent.swift b/Sources/Observability/TouchEvent.swift new file mode 100644 index 0000000..2fb3119 --- /dev/null +++ b/Sources/Observability/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/Observability/UIWindowSendEvent.swift b/Sources/Observability/UIWindowSendEvent.swift new file mode 100644 index 0000000..285207b --- /dev/null +++ b/Sources/Observability/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)) + } +}