Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.1
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
Expand Down
24 changes: 24 additions & 0 deletions Sources/Client/InstrumentationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import OpenTelemetrySdk
import StdoutExporter
import OpenTelemetryProtocolExporterHttp

import UIKit.UIWindow

import API
import Interfaces
import Common
Expand All @@ -26,6 +28,8 @@ public final class InstrumentationManager {
private var cachedLongCounters = AtomicDictionary<String, LongCounter>()
private var cachedHistograms = AtomicDictionary<String, DoubleHistogram>()
private var cachedUpDownCounters = AtomicDictionary<String, DoubleUpDownCounter>()
private let lock: NSLock = NSLock()
private let handler = TapHandler()

public init(sdkKey: String, options: Options, sessionManager: SessionManager) {
self.sdkKey = sdkKey
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Client/ObservabilityClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public final class ObservabilityClient: Observe {
private let options: Options

private var cachedSpans = AtomicDictionary<String, Span>()
private var task: Task<Void, Never>?
private let crashReporter: CrashReporter
private let urlSessionInstrumentation: URLSessionInstrumentation

Expand Down Expand Up @@ -62,6 +61,8 @@ public final class ObservabilityClient: Observe {
)
}

// MARK: - Instrumentation

public func recordMetric(metric: Metric) {
instrumentationManager.recordMetric(metric: metric)
}
Expand Down
40 changes: 40 additions & 0 deletions Sources/Client/TapHandler.swift
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

how does this work with multiple touches that happen at the same time (multiple fingers) - would those be different touch events?

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
}
}
}
}
20 changes: 20 additions & 0 deletions Sources/Client/TouchEvent.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
38 changes: 38 additions & 0 deletions Sources/Client/UIWindowSendEvent.swift
Original file line number Diff line number Diff line change
@@ -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<IMP>.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))
}
}
7 changes: 7 additions & 0 deletions Sources/Common/CGFloat+ToString.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

extension CGFloat {
public func toString() -> String {
String(format: "%.2f", self)
}
}
3 changes: 3 additions & 0 deletions Sources/CrashReporterLive/LaunchDarklyCrashFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")))
Expand Down