Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: recording network requests #125

Merged
merged 4 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 16 additions & 0 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@
69EE82BC2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */; };
69EE82BE2BA9C8AA00EB9542 /* ViewLayoutTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EE82BD2BA9C8AA00EB9542 /* ViewLayoutTracker.swift */; };
69EE82CE2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EE82CD2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift */; };
69F23A742BB3088E001194F6 /* URLSessionSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A732BB3088E001194F6 /* URLSessionSwizzler.swift */; };
69F23A762BB308AE001194F6 /* URLSessionInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A752BB308AE001194F6 /* URLSessionInterceptor.swift */; };
69F23A782BB30991001194F6 /* NetworkSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A772BB30991001194F6 /* NetworkSample.swift */; };
69F23A7A2BB309F3001194F6 /* MethodSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A792BB309F3001194F6 /* MethodSwizzler.swift */; };
69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E72BAC675800F52C14 /* RRWireframe.swift */; };
69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E92BAC684F00F52C14 /* RRStyle.swift */; };
69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517F22BAC734300F52C14 /* UIColor+Util.swift */; };
Expand Down Expand Up @@ -327,6 +331,10 @@
69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayConfig.swift; sourceTree = "<group>"; };
69EE82BD2BA9C8AA00EB9542 /* ViewLayoutTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewLayoutTracker.swift; sourceTree = "<group>"; };
69EE82CD2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTreeSnapshotStatus.swift; sourceTree = "<group>"; };
69F23A732BB3088E001194F6 /* URLSessionSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionSwizzler.swift; sourceTree = "<group>"; };
69F23A752BB308AE001194F6 /* URLSessionInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionInterceptor.swift; sourceTree = "<group>"; };
69F23A772BB30991001194F6 /* NetworkSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSample.swift; sourceTree = "<group>"; };
69F23A792BB309F3001194F6 /* MethodSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSwizzler.swift; sourceTree = "<group>"; };
69F517E72BAC675800F52C14 /* RRWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRWireframe.swift; sourceTree = "<group>"; };
69F517E92BAC684F00F52C14 /* RRStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRStyle.swift; sourceTree = "<group>"; };
69F517F22BAC734300F52C14 /* UIColor+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Util.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -667,6 +675,10 @@
69F518172BAC80A300F52C14 /* String+Util.swift */,
69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */,
69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */,
69F23A732BB3088E001194F6 /* URLSessionSwizzler.swift */,
69F23A752BB308AE001194F6 /* URLSessionInterceptor.swift */,
69F23A772BB30991001194F6 /* NetworkSample.swift */,
69F23A792BB309F3001194F6 /* MethodSwizzler.swift */,
);
path = Replay;
sourceTree = "<group>";
Expand Down Expand Up @@ -1034,6 +1046,8 @@
69EE82BE2BA9C8AA00EB9542 /* ViewLayoutTracker.swift in Sources */,
69261D1F2AD9681300232EC7 /* PostHogConsumerPayload.swift in Sources */,
69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */,
69F23A782BB30991001194F6 /* NetworkSample.swift in Sources */,
69F23A762BB308AE001194F6 /* URLSessionInterceptor.swift in Sources */,
690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */,
69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */,
3AE3FB4E2993D1D600AFFC18 /* PostHogSessionManager.swift in Sources */,
Expand All @@ -1046,6 +1060,7 @@
690FF0AF2AEB9C1400A0B06B /* DateUtils.swift in Sources */,
69F518162BAC7F9200F52C14 /* UIView+Util.swift in Sources */,
69261D192AD9673500232EC7 /* PostHogBatchUploadInfo.swift in Sources */,
69F23A742BB3088E001194F6 /* URLSessionSwizzler.swift in Sources */,
69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */,
69F5183A2BB2BA8300F52C14 /* UIApplicationTracker.swift in Sources */,
69F518182BAC80A300F52C14 /* String+Util.swift in Sources */,
Expand All @@ -1059,6 +1074,7 @@
690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */,
3AE3FB332991388500AFFC18 /* PostHogQueue.swift in Sources */,
690FF0B52AEBBD3C00A0B06B /* DictUtils.swift in Sources */,
69F23A7A2BB309F3001194F6 /* MethodSwizzler.swift in Sources */,
69261D1B2AD9678C00232EC7 /* PostHogEvent.swift in Sources */,
69EE82BC2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift in Sources */,
69EE82CE2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift in Sources */,
Expand Down
120 changes: 120 additions & 0 deletions PostHog/Replay/MethodSwizzler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2019-Present Datadog, Inc.
*/

#if os(iOS)
import Foundation

class MethodSwizzler<TypedIMP, TypedBlockIMP> {
struct FoundMethod: Hashable {
let method: Method
private let klass: AnyClass

fileprivate init(method: Method, klass: AnyClass) {
self.method = method
self.klass = klass
}

static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool {
let methodParity = (lhs.method == rhs.method)
let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass))
return methodParity && classParity
}

func hash(into hasher: inout Hasher) {
let methodName = NSStringFromSelector(method_getName(method))
let klassName = NSStringFromClass(klass)
let identifier = "\(methodName)|||\(klassName)"
hasher.combine(identifier)
}
}

private var implementationCache: [FoundMethod: IMP] = [:]
var swizzledMethods: [FoundMethod] {
Array(implementationCache.keys)
}

static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod {
/// NOTE: RUMM-452 as we never add/remove methods/classes at runtime,
/// search operation doesn't have to wrapped in sync {...} although it's visible in the interface
var headKlass: AnyClass? = klass
while let someKlass = headKlass {
if let foundMethod = findMethod(with: selector, in: someKlass) {
return FoundMethod(method: foundMethod, klass: someKlass)
}
headKlass = class_getSuperclass(headKlass)
}
throw InternalPostHogError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))")
}

func originalImplementation(of found: FoundMethod) -> TypedIMP {
sync {
let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method)
return unsafeBitCast(originalImp, to: TypedIMP.self)
}
}

func swizzle(
_ foundMethod: FoundMethod,
impProvider: (TypedIMP) -> TypedBlockIMP
) {
sync {
let currentIMP = method_getImplementation(foundMethod.method)
let currentTypedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self)
let newImpBlock: TypedBlockIMP = impProvider(currentTypedIMP)
let newImp: IMP = imp_implementationWithBlock(newImpBlock)

set(newIMP: newImp, for: foundMethod)
}
}

/// Removes swizzling and resets the method to its original implementation.
func unswizzle() {
for foundMethod in swizzledMethods {
let originalTypedIMP = originalImplementation(of: foundMethod)
let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self)
method_setImplementation(foundMethod.method, originalIMP)
}
}

// MARK: - Private methods

@discardableResult
private func sync<T>(block: () -> T) -> T {
objc_sync_enter(self)
defer { objc_sync_exit(self) }
return block()
}

private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? {
var methodsCount: UInt32 = 0
let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 }
guard let methods: UnsafeMutablePointer<Method> = class_copyMethodList(klass, methodsCountPtr) else {
return nil
}
defer {
free(methods)
}
for index in 0 ..< Int(methodsCount) {
let method = methods.advanced(by: index).pointee
if method_getName(method) == selector {
return method
}
}
return nil
}

private func set(newIMP: IMP, for found: FoundMethod) {
if implementationCache[found] == nil {
implementationCache[found] = method_getImplementation(found.method)
}
method_setImplementation(found.method, newIMP)
}
}

extension MethodSwizzler.FoundMethod {
var swizzlingName: String { "\(klass).\(method_getName(method))" }
}
#endif
56 changes: 56 additions & 0 deletions PostHog/Replay/NetworkSample.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// NetworkSample.swift
// PostHog
//
// Created by Manoel Aranda Neto on 26.03.24.
//

#if os(iOS)
import Foundation

struct NetworkSample {
let timeOrigin: Date
let entryType = "resource"
var name: String?
var responseStatus: Int?
var initiatorType = "fetch"
var httpMethod: String?
var duration: Int64?
var decodedBodySize: Int64?

init(timeOrigin: Date, url: String? = nil) {
self.timeOrigin = timeOrigin
name = url
}

func toDict() -> [String: Any] {
var dict: [String: Any] = [
"timestamp": timeOrigin.toMillis(),
"entryType": entryType,
"initiatorType": initiatorType,
]

if let name = name {
dict["name"] = name
}

if let responseStatus = responseStatus {
dict["responseStatus"] = responseStatus
}

if let httpMethod = httpMethod {
dict["method"] = httpMethod
}

if let duration = duration {
dict["duration"] = duration
}

if let decodedBodySize = decodedBodySize {
dict["transferSize"] = decodedBodySize
}

return dict
}
}
#endif
24 changes: 21 additions & 3 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@
private var timer: Timer?

private let windowViews = NSMapTable<UIView, ViewTreeSnapshotStatus>.weakToStrongObjects()
private let urlInterceptor: URLSessionInterceptor
private var sessionSwizzler: URLSessionSwizzler?

init(_ config: PostHogConfig) {
self.config = config
urlInterceptor = URLSessionInterceptor(self.config)
do {
try sessionSwizzler = URLSessionSwizzler(interceptor: urlInterceptor)
} catch {
hedgeLog("Error trying to Swizzle URLSession: \(error)")
}
}

func start() {
Expand All @@ -34,13 +42,20 @@
ViewLayoutTracker.swizzleLayoutSubviews()

UIApplicationTracker.swizzleSendEvent()

if config.sessionReplayConfig.captureNetworkTelemetry {
sessionSwizzler?.swizzle()
}
}

func stop() {
stopTimer()
ViewLayoutTracker.unSwizzleLayoutSubviews()
windowViews.removeAllObjects()
UIApplicationTracker.unswizzleSendEvent()

sessionSwizzler?.unswizzle()
urlInterceptor.stop()
}

private func stopTimer() {
Expand Down Expand Up @@ -128,7 +143,8 @@

if let textView = view as? UITextView {
wireframe.type = "text"
wireframe.text = (config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText()) ? textView.text.mask() : textView.text
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText()
wireframe.text = isSensitive ? textView.text.mask() : textView.text
wireframe.disabled = !textView.isEditable
style.color = textView.textColor?.toRGBString()
style.fontFamily = textView.font?.familyName
Expand All @@ -143,10 +159,12 @@
wireframe.type = "input"
wireframe.inputType = "text_area"
if let text = textField.text {
wireframe.value = (config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()) ? text.mask() : text
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()
wireframe.value = isSensitive ? text.mask() : text
} else {
if let text = textField.placeholder {
wireframe.value = (config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()) ? text.mask() : text
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText()
wireframe.value = isSensitive ? text.mask() : text
}
}
wireframe.disabled = !textField.isEnabled
Expand Down
5 changes: 5 additions & 0 deletions PostHog/Replay/PostHogSessionReplayConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
/// Default: true
@objc public var maskAllImages: Bool = true

/// Enable capturing network telemetry
/// Experimental support
/// Default: false
@objc public var captureNetworkTelemetry: Bool = false

// TODO: sessionRecording config such as networkPayloadCapture, captureConsoleLogs, sampleRate, etc
}
#endif
Loading
Loading