From dc25e44b57a3eb3fee292388cc7f2f9d64ef071c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 21 Nov 2024 13:40:39 +0200 Subject: [PATCH] feat: base implementation of custom URLProtocol --- PostHog.xcodeproj/project.pbxproj | 12 + PostHog/PostHogSDK.swift | 4 + PostHog/PostHogSwizzler.swift | 7 + PostHog/Replay/NetworkRequestSample.swift | 257 ++++++++++++++++ .../Replay/PostHogDefaultHTTPProtocol.swift | 281 ++++++++++++++++++ .../PostHogNetworkCaptureIntegration.swift | 47 +++ PostHog/Replay/PostHogReplayIntegration.swift | 27 +- 7 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 PostHog/Replay/NetworkRequestSample.swift create mode 100644 PostHog/Replay/PostHogDefaultHTTPProtocol.swift create mode 100644 PostHog/Replay/PostHogNetworkCaptureIntegration.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 25eed903c..2d78923d9 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -120,6 +120,8 @@ 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; 69F5183A2BB2BA8300F52C14 /* UIApplicationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */; }; DA26419C2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */; }; + DA2B1A142CDE69E200149627 /* PostHogHTTPProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B1A132CDE69E200149627 /* PostHogHTTPProtocol.swift */; }; + DA2B1A282CE2248600149627 /* NetworkRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B1A222CE2248300149627 /* NetworkRequest.swift */; }; DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */; }; DA5B85882CD21CBB00686389 /* AutocaptureEventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */; }; DA979D7B2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */; }; @@ -127,6 +129,7 @@ DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; }; DACF6D5D2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */; }; DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */; }; + DADF32272CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADF32262CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -391,6 +394,8 @@ 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationTracker.swift; sourceTree = ""; }; DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTracker.swift; sourceTree = ""; }; + DA2B1A132CDE69E200149627 /* PostHogHTTPProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogHTTPProtocol.swift; sourceTree = ""; }; + DA2B1A222CE2248300149627 /* NetworkRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequest.swift; sourceTree = ""; }; DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = ""; }; DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; @@ -399,6 +404,7 @@ DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardingPickerViewDelegate.swift; sourceTree = ""; }; DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegrationSpec.swift; sourceTree = ""; }; DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = ""; }; + DADF32262CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogNetworkCaptureIntegration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -724,6 +730,9 @@ isa = PBXGroup; children = ( 69EE82B92BA9C50400EB9542 /* PostHogReplayIntegration.swift */, + DADF32262CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift */, + DA2B1A222CE2248300149627 /* NetworkRequest.swift */, + DA2B1A132CDE69E200149627 /* PostHogHTTPProtocol.swift */, 69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */, 69EE82BD2BA9C8AA00EB9542 /* ViewLayoutTracker.swift */, 69EE82CD2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift */, @@ -1153,6 +1162,7 @@ DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */, 6926DA8E2ADD2876005760D2 /* PostHogContext.swift in Sources */, 690FF0AF2AEB9C1400A0B06B /* DateUtils.swift in Sources */, + DA2B1A142CDE69E200149627 /* PostHogHTTPProtocol.swift in Sources */, 69F518162BAC7F9200F52C14 /* UIView+Util.swift in Sources */, 69261D192AD9673500232EC7 /* PostHogBatchUploadInfo.swift in Sources */, DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */, @@ -1170,6 +1180,7 @@ 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */, 3AE3FB3D29924E8200AFFC18 /* PostHogSDK.swift in Sources */, 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */, + DADF32272CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift in Sources */, 3AE3FB3F29924F4F00AFFC18 /* PostHogConfig.swift in Sources */, 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */, DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */, @@ -1179,6 +1190,7 @@ 69F23A7A2BB309F3001194F6 /* MethodSwizzler.swift in Sources */, 69261D1B2AD9678C00232EC7 /* PostHogEvent.swift in Sources */, 69EE82BC2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift in Sources */, + DA2B1A282CE2248600149627 /* NetworkRequest.swift in Sources */, DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */, 69EE82CE2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift in Sources */, 69ED1AD42C90A0F100FE7A91 /* URLSessionExtension.swift in Sources */, diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 613280930..86359db4f 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1236,6 +1236,10 @@ let maxRetryDelay = 30.0 return config.sessionReplay && isSessionActive() && (featureFlags?.isSessionReplayFlagActive() ?? false) } + + @objc public func isCaptureNetworkTelemetryEnabled() -> Bool { + config.sessionReplay && config.sessionReplayConfig.captureNetworkTelemetry + } #endif #if os(iOS) || targetEnvironment(macCatalyst) diff --git a/PostHog/PostHogSwizzler.swift b/PostHog/PostHogSwizzler.swift index 7aaf73c4d..ee9722a00 100644 --- a/PostHog/PostHogSwizzler.swift +++ b/PostHog/PostHogSwizzler.swift @@ -12,3 +12,10 @@ func swizzle(forClass: AnyClass, original: Selector, new: Selector) { guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) } + +func swizzleClassMethod(forClass: AnyClass, original: Selector, new: Selector) { + guard let c = object_getClass(forClass) else { return } + guard let originalMethod = class_getClassMethod(c, original) else { return } + guard let swizzledMethod = class_getClassMethod(c, new) else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) +} diff --git a/PostHog/Replay/NetworkRequestSample.swift b/PostHog/Replay/NetworkRequestSample.swift new file mode 100644 index 000000000..1ad2799b9 --- /dev/null +++ b/PostHog/Replay/NetworkRequestSample.swift @@ -0,0 +1,257 @@ +// +// NetworkRequestSample.swift +// PostHog +// +// Created by Yiannis Josephides on 11/11/2024. +// + +#if os(iOS) + import Foundation + + private let jsonRegex: String = "^application/.*json" + private let xmlRegex: String = "^application/.*xml" + + class NetworkRequestSample: Identifiable { + enum ContentType: String { + case json + case xml + case html + case image + case other + + init(contentType: String) { + switch contentType { + case _ where contentType.matches(jsonRegex): + self = .json + case _ where contentType.matches(xmlRegex) || contentType == "text/xml": + self = .xml + case "text/html": + self = .html + case _ where contentType.hasPrefix("image/"): + self = .image + default: + self = .other + } + } + } + + lazy var id = UUID().uuidString + + var timestamp = getCurrentTimeMilliseconds() + var timeOrigin = getMonotonicTimeInMilliseconds() + + var requestStartTime: UInt64? + var requestURL: URL? + var requestMethod: String? + var requestHeaders: [String: Any]? + var requestContentType: ContentType? + var requestContentTypeRaw: String? + var requestBodyStr: String? + var requestBodyLength: Int? + + var responseError: String? + var responseData: NSMutableData? + var responseStatus: Int? + var responseContentType: ContentType? + var responseContentTypeRaw: String? + var responseStartTime: UInt64? + var responseEndTime: UInt64? + var responseHeaders: [String: Any]? + var responseBodyStr: String? + var responseBodyLength: Int? + + var durationMs: UInt64? + + var isProcessed: Bool = false + + // called when a request starts loading + func start(request: URLRequest) { + requestStartTime = getMonotonicTimeInMilliseconds() + requestURL = request.url?.absoluteURL + requestMethod = request.httpMethod + requestHeaders = request.normalizedHeaderFields ?? [:] + + // grab content-type. Keys normalized with .lowercase() + if let contentType = requestHeaders?["content-type"] as? String { + let contentType = contentType.components(separatedBy: ";")[0] + requestContentTypeRaw = contentType + requestContentType = ContentType(contentType: contentType) + } + + // grab request body + if let requestData = request.httpBody ?? request.httpBodyStream?.consume() { + if let responseContentType, responseContentType == .image { + // don't record response body for image types + let bodyStr = requestData.base64EncodedString(options: .endLineWithLineFeed) + requestBodyLength = bodyStr.count + } else if let utfString = String(data: requestData, encoding: String.Encoding.utf8) { + requestBodyStr = utfString + requestBodyLength = utfString.count + } + } + } + + // called on stopLoading (request was cancelled) + func stop() { + durationMs = relative(getMonotonicTimeInMilliseconds(), to: requestStartTime) + } + + // called on didCompleteWithError + func complete(response: URLResponse, error: Error?) { + let completedTime = getMonotonicTimeInMilliseconds() + responseEndTime = completedTime + responseStatus = (response as? HTTPURLResponse)?.statusCode + responseHeaders = response.normalizedHeaderFields ?? [:] + responseError = error?.localizedDescription + + if let contentType = responseHeaders?["content-type"] as? String { + let contentType = contentType.components(separatedBy: ";")[0] + responseContentTypeRaw = contentType + responseContentType = ContentType(contentType: contentType) + } + + durationMs = relative(completedTime, to: requestStartTime) + + if let responseData = responseData as? Data { + if let responseContentType, responseContentType == .image { + // don't record response body for image types + let bodyStr = responseData.base64EncodedString(options: .endLineWithLineFeed) + responseBodyLength = bodyStr.count + } else if let utfString = String(data: responseData, encoding: String.Encoding.utf8) { + responseBodyStr = utfString + responseBodyLength = utfString.count + } + } + } + + // called after startReceivingData when didReceiveData + func didReceive(data: Data) { + if responseStartTime == nil { + responseStartTime = getMonotonicTimeInMilliseconds() + responseData = NSMutableData() + } + responseData?.append(data) + } + + // sample was queued upstream - for debug purposes + func markProcessed() { + isProcessed = true + } + } + + private func getMonotonicTimeInMilliseconds() -> UInt64 { + // Get the raw mach time + let machTime = mach_absolute_time() + + // Get timebase info to convert to nanoseconds + var timebaseInfo = mach_timebase_info_data_t() + mach_timebase_info(&timebaseInfo) + + // Convert mach time to nanoseconds + let nanoTime = machTime * UInt64(timebaseInfo.numer) / UInt64(timebaseInfo.denom) + + // Convert nanoseconds to milliseconds + let milliTime = nanoTime / NSEC_PER_MSEC + + return milliTime + } + + private func getCurrentTimeMilliseconds() -> UInt64 { + UInt64(now().timeIntervalSince1970) * MSEC_PER_SEC + } + + extension NetworkRequestSample { + func toDict() -> [String: Any] { + [ + "entryType": "resource", + "initiatorType": getInitiatorType(), + "name": requestURL?.absoluteString, + "method": requestMethod, + + "transferSize": responseData?.length, + "timestamp": timestamp, + "duration": durationMs, + + "requestStart": relative(toOrigin: requestStartTime), + "requestBody": requestBodyStr, + "requestHeaders": requestHeaders, + + "responseStart": relative(toOrigin: responseStartTime), + "responseEnd": relative(toOrigin: responseEndTime), + "responseStatus": responseStatus, + "responseBody": responseBodyStr, + "responseHeaders": responseHeaders, + + "startTime": 0, // always zero, needed for timeline views + "endTime": relative(responseEndTime, to: requestStartTime), + ].compactMapValues { $0 } + } + + func getInitiatorType() -> String? { + guard let type = requestContentType ?? responseContentType else { + return "other" + } + return switch type { + case .json, .html: "fetch" + case .image: "img" + case .xml: "xmlhttprequest" + case .other: "other" + } + } + + func relative(toOrigin time: UInt64?) -> UInt64? { + relative(time, to: timeOrigin) + } + + func relative(_ date: UInt64?, to dateOrigin: UInt64?) -> UInt64? { + guard let date, let dateOrigin, date >= dateOrigin else { return nil } + return date - dateOrigin + } + } + + extension InputStream { + func consume() -> Data { + open() + defer { close() } + + var data = Data() + let bufferSize = 4096 // 4KB - typical buffer size + var buffer = [UInt8](repeating: 0, count: bufferSize) + var bytesRead = 0 + + repeat { + bytesRead = read(&buffer, maxLength: bufferSize) + if bytesRead > 0 { + data.append(buffer, count: bytesRead) + } + } while bytesRead > 0 + + return data + } + } + + extension String { + func matches(_ regex: String) -> Bool { + range(of: regex, options: .regularExpression, range: nil) != nil + } + } + + extension URLRequest { + var normalizedHeaderFields: [String: Any]? { + guard let headers = allHTTPHeaderFields else { return nil } + return Dictionary(uniqueKeysWithValues: headers.map { key, value in + (String(describing: key).lowercased(), "\(value)") + }) + } + } + + extension URLResponse { + var normalizedHeaderFields: [String: Any]? { + guard let headers = (self as? HTTPURLResponse)?.allHeaderFields else { return nil } + return Dictionary(uniqueKeysWithValues: headers.map { key, value in + (String(describing: key).lowercased(), "\(value)") + }) + } + } + +#endif diff --git a/PostHog/Replay/PostHogDefaultHTTPProtocol.swift b/PostHog/Replay/PostHogDefaultHTTPProtocol.swift new file mode 100644 index 000000000..3862025bc --- /dev/null +++ b/PostHog/Replay/PostHogDefaultHTTPProtocol.swift @@ -0,0 +1,281 @@ +// +// PostHogDefaultHTTPProtocol.swift +// PostHog +// +// Created by Yiannis Josephides on 07/11/2024. +// + +#if os(iOS) + import Foundation + + private let RequestHandledKey = "PHRequestHandled" + + /** + URLProtocol is a part of the Foundation framework in iOS, macOS, and other Apple platforms. + + Key Methods: + - URLProtocol has 4 key methods + - `canInit(with:)`: Called by the system to determine whether the URLProtocol instance should handle a specific request + - `canonicalRequest(for:)`: Called right after canInit(with:) returns true and gives us the opportunity to modify the request in any way before feeding it back to the system + - `startLoading()`: Called to begin processing the request and do the work needed + - `stopLoading()`: Called in the event that the request was canceled + + NOTE: `URLProtocol` implementations need to be registered by calling `URLProtocol.registerClass()` before they can be visible to the URL loading system. If a class is not registered, then `canInit(with:)` will never be called + + NOTE: `URLSessionConfiguration.protocolClasses` - The system calls `canInit(with:)` for each protocol class in the order they are listed here. Therefore, custom protocols are typically inserted at index 0 to ensure higher priority. + */ + final class PostHogHTTPProtocol: URLProtocol { + private var session: URLSession? + private var sessionDataTask: URLSessionDataTask? + private var currentSample = NetworkRequestSample() + private var response: URLResponse? + + override init(request: URLRequest, cachedResponse: CachedURLResponse?, client: URLProtocolClient?) { + super.init(request: request, cachedResponse: cachedResponse, client: client) + + if session == nil { + // ⚠️ - This is currently always using a fresh session with the `default` configuration + // - Need to figure out a way to use custom sessions and configurations here + session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + } + } + + override public class func canInit(with request: URLRequest) -> Bool { + canHandle(request: request) + } + + override class func canInit(with task: URLSessionTask) -> Bool { + canHandle(task: task) + } + + private static func canHandle(task: URLSessionTask) -> Bool { + if #available(iOS 13.0, macOS 10.15, *) { + // No ws support for now + if task is URLSessionWebSocketTask { + return false + } + } + + guard let request = task.currentRequest else { return false } + return canHandle(request: request) + } + + private static func canHandle(request: URLRequest) -> Bool { + guard PostHogSDK.shared.isCaptureNetworkTelemetryEnabled() else { return false } + + guard shouldHandleHost(request) else { return false } + + // check if this request has already been handled + guard !isHandling(request: request) else { return false } + + return true + } + + private class func shouldHandleHost(_: URLRequest) -> Bool { + // just a placeholder for now, we could potentially choose to ignore/allow some hosts from config + true + } + + override public func startLoading() { + // mark as handled + let request = Self.markHandling(request: request) + // collect info + currentSample.start(request: request) + // execute + sessionDataTask = session?.dataTask(with: request) + sessionDataTask?.resume() + } + + override public func stopLoading() { + currentSample.stop() + sessionDataTask?.cancel() + session?.invalidateAndCancel() + } + + override public class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + deinit { + session = nil + sessionDataTask = nil + } + + private func processCurrentSample() { + var snapshotsData: [Any] = [] + let requestsData = [currentSample.toDict()] + let payloadData: [String: Any] = [ + "requests": requestsData, + ] + let pluginData: [String: Any] = [ + "plugin": "rrweb/network@1", + "payload": payloadData, + ] + + let data: [String: Any] = [ + "type": 6, + "data": pluginData, + "timestamp": currentSample.timestamp, + ] + + snapshotsData.append(data) + + PostHogSDK.shared.capture("$snapshot", properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + ]) + + currentSample.markProcessed() + } + + private static func isHandling(request: URLRequest) -> Bool { + property(forKey: RequestHandledKey, in: request) as? Bool ?? false + } + + private static func markHandling(request originalRequest: URLRequest) -> URLRequest { + let request: URLRequest + if property(forKey: RequestHandledKey, in: originalRequest) == nil { + let mutableRequest = originalRequest.asMutableURLRequest + setProperty(true, forKey: RequestHandledKey, in: mutableRequest) + request = mutableRequest as URLRequest + } else { + request = originalRequest + } + + return request + } + + private static func markNotHandling(request originalRequest: URLRequest) -> URLRequest { + let request: URLRequest + if property(forKey: RequestHandledKey, in: originalRequest) != nil { + let mutableRequest = originalRequest.asMutableURLRequest + setProperty(false, forKey: RequestHandledKey, in: mutableRequest) + request = mutableRequest as URLRequest + } else { + request = originalRequest + } + + return request + } + } + + extension PostHogHTTPProtocol { + class func enable(_ enable: Bool, session: URLSession) { + // preferredSession = session + self.enable(enable, sessionConfiguration: session.configuration) + } + + class func enable(_ enable: Bool, sessionConfiguration: URLSessionConfiguration) { + var urlProtocolClasses = sessionConfiguration.protocolClasses ?? [AnyClass]() + let phProtocolClass = Self.self + + let index = urlProtocolClasses.firstIndex(where: { obj in obj == phProtocolClass }) + + if enable, index == nil { // de-duped + urlProtocolClasses.insert(phProtocolClass, at: 0) + } else if !enable, let index { + urlProtocolClasses.remove(at: index) + } + + sessionConfiguration.protocolClasses = urlProtocolClasses + } + } + + extension PostHogHTTPProtocol: URLSessionDataDelegate { + public func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive data: Data) { + hedgeLog("[Network] did receive data \(data)") + currentSample.didReceive(data: data) + client?.urlProtocol(self, didLoad: data) + } + + func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition { + let policy = URLCache.StoragePolicy(rawValue: request.cachePolicy.rawValue) ?? .notAllowed + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: policy) + hedgeLog("[Network] did receive response \(response)") + self.response = response + + return .allow + } + + public func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) { + defer { + if let error { + client?.urlProtocol(self, didFailWithError: error) + } else { + client?.urlProtocolDidFinishLoading(self) + } + + processCurrentSample() + } + + if let response { + currentSample.complete(response: response, error: error) + } + } + + public func urlSession(_: URLSession, task _: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + hedgeLog("[Network] will perform http redirect \(response.statusCode) \(request.url?.absoluteString ?? "")") + + let theRequest = Self.markNotHandling(request: request) + + client?.urlProtocol(self, wasRedirectedTo: theRequest, redirectResponse: response) + completionHandler(theRequest) + } + + public func urlSession(_: URLSession, didBecomeInvalidWithError error: Error?) { + guard let error = error else { return } + hedgeLog("[Network] did become invalid with error \(error)") + client?.urlProtocol(self, didFailWithError: error) + } + + public func urlSession(_: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + hedgeLog("[Network] did receive authentication challenge \(challenge)") + let wrappedChallenge = URLAuthenticationChallenge( + authenticationChallenge: challenge, + sender: PostHogAuthenticationChallengeSender(handler: completionHandler) + ) + client?.urlProtocol(self, didReceive: wrappedChallenge) + } + + public func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) { + client?.urlProtocolDidFinishLoading(self) + } + } + + final class PostHogAuthenticationChallengeSender: NSObject, URLAuthenticationChallengeSender { + typealias AuthenticationChallengeCompletionHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + let handler: AuthenticationChallengeCompletionHandler + + init(handler: @escaping AuthenticationChallengeCompletionHandler) { + self.handler = handler + super.init() + } + + func use(_ credential: URLCredential, for _: URLAuthenticationChallenge) { + handler(.useCredential, credential) + } + + func continueWithoutCredential(for _: URLAuthenticationChallenge) { + handler(.useCredential, nil) + } + + func cancel(_: URLAuthenticationChallenge) { + handler(.cancelAuthenticationChallenge, nil) + } + + func performDefaultHandling(for _: URLAuthenticationChallenge) { + handler(.performDefaultHandling, nil) + } + + func rejectProtectionSpaceAndContinue(with _: URLAuthenticationChallenge) { + handler(.rejectProtectionSpace, nil) + } + } + + extension URLRequest { + var asMutableURLRequest: NSMutableURLRequest { + ((self as NSURLRequest).mutableCopy() as? NSMutableURLRequest)! + } + } + +#endif diff --git a/PostHog/Replay/PostHogNetworkCaptureIntegration.swift b/PostHog/Replay/PostHogNetworkCaptureIntegration.swift new file mode 100644 index 000000000..1e3287217 --- /dev/null +++ b/PostHog/Replay/PostHogNetworkCaptureIntegration.swift @@ -0,0 +1,47 @@ +// +// PostHogNetworkCaptureIntegration.swift +// PostHog +// +// Created by Yiannis Josephides on 20/11/2024. +// + +#if os(iOS) + import Foundation + + final class PostHogNetworkCaptureIntegration { + private init() {} + + private static var hasSwizzled = false + static func setupNetworkCapture() { + guard !hasSwizzled else { return } + hasSwizzled = true + + URLProtocol.registerClass(PostHogHTTPProtocol.self) + + PostHog.swizzleClassMethod( + forClass: URLSessionConfiguration.self, + original: #selector(getter: URLSessionConfiguration.default), + new: #selector(URLSessionConfiguration.ph_swizzled_default_getter) + ) + PostHog.swizzleClassMethod( + forClass: URLSessionConfiguration.self, + original: #selector(getter: URLSessionConfiguration.ephemeral), + new: #selector(URLSessionConfiguration.ph_swizzled_ephemeral_getter) + ) + } + } + + private extension URLSessionConfiguration { + @objc class func ph_swizzled_default_getter() -> URLSessionConfiguration { + let original = ph_swizzled_default_getter() + PostHogHTTPProtocol.enable(true, sessionConfiguration: original) + return original + } + + @objc class func ph_swizzled_ephemeral_getter() -> URLSessionConfiguration { + let original = ph_swizzled_ephemeral_getter() + PostHogHTTPProtocol.enable(true, sessionConfiguration: original) + return original + } + } +#endif diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index ea22d2884..93009f1e9 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -18,8 +18,8 @@ private var timer: Timer? private let windowViews = NSMapTable.weakToStrongObjects() - private let urlInterceptor: URLSessionInterceptor - private var sessionSwizzler: URLSessionSwizzler? +// private let urlInterceptor: URLSessionInterceptor +// private var sessionSwizzler: URLSessionSwizzler? /** ### Mapping of SwiftUI Views to UIKit @@ -88,12 +88,12 @@ init(_ config: PostHogConfig) { self.config = config - urlInterceptor = URLSessionInterceptor(self.config) - do { - try sessionSwizzler = URLSessionSwizzler(interceptor: urlInterceptor) - } catch { - hedgeLog("Error trying to Swizzle URLSession: \(error)") - } +// urlInterceptor = URLSessionInterceptor(self.config) +// do { +// try sessionSwizzler = URLSessionSwizzler(interceptor: urlInterceptor) +// } catch { +// hedgeLog("Error trying to Swizzle URLSession: \(error)") +// } } func start() { @@ -108,7 +108,8 @@ UIApplicationTracker.swizzleSendEvent() if config.sessionReplayConfig.captureNetworkTelemetry { - sessionSwizzler?.swizzle() +// sessionSwizzler?.swizzle() + PostHogNetworkCaptureIntegration.setupNetworkCapture() } } @@ -118,8 +119,8 @@ windowViews.removeAllObjects() UIApplicationTracker.unswizzleSendEvent() - sessionSwizzler?.unswizzle() - urlInterceptor.stop() + // sessionSwizzler?.unswizzle() + // urlInterceptor.stop() } private func stopTimer() { @@ -576,6 +577,10 @@ return wireframe } + private func isCaptureNetworkEnabled() -> Bool { + config.sessionReplayConfig.captureNetworkTelemetry && PostHogSDK.shared.isSessionReplayActive() + } + @objc private func snapshot() { if !PostHogSDK.shared.isSessionReplayActive() { return