Skip to content

Commit

Permalink
feat: base implementation of custom URLProtocol
Browse files Browse the repository at this point in the history
  • Loading branch information
ioannisj committed Nov 21, 2024
1 parent f8d5524 commit dc25e44
Show file tree
Hide file tree
Showing 7 changed files with 624 additions and 11 deletions.
12 changes: 12 additions & 0 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,16 @@
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 */; };
DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */; };
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 */
Expand Down Expand Up @@ -391,6 +394,8 @@
69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = "<group>"; };
69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationTracker.swift; sourceTree = "<group>"; };
DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTracker.swift; sourceTree = "<group>"; };
DA2B1A132CDE69E200149627 /* PostHogHTTPProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogHTTPProtocol.swift; sourceTree = "<group>"; };
DA2B1A222CE2248300149627 /* NetworkRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequest.swift; sourceTree = "<group>"; };
DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = "<group>"; };
DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = "<group>"; };
DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = "<group>"; };
Expand All @@ -399,6 +404,7 @@
DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardingPickerViewDelegate.swift; sourceTree = "<group>"; };
DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegrationSpec.swift; sourceTree = "<group>"; };
DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = "<group>"; };
DADF32262CEE03FD0004A6EA /* PostHogNetworkCaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogNetworkCaptureIntegration.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand All @@ -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 */,
Expand Down
4 changes: 4 additions & 0 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions PostHog/PostHogSwizzler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
257 changes: 257 additions & 0 deletions PostHog/Replay/NetworkRequestSample.swift
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit dc25e44

Please sign in to comment.