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

feat: refactor network logs instrumentation #260

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
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) {
Copy link
Member

Choose a reason for hiding this comment

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

maybe rename the one above to make more clear its about instance method?

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 ?? [:]
Copy link
Member

Choose a reason for hiding this comment

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


// 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() {
Copy link
Member

Choose a reason for hiding this comment

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

since this has a performance impact + PII risk, we should add a new flag captureBody as part of session replay config

if let responseContentType, responseContentType == .image {
// don't record response body for image types
let bodyStr = requestData.base64EncodedString(options: .endLineWithLineFeed)
requestBodyLength = bodyStr.count
Comment on lines +84 to +86
Copy link
Member

Choose a reason for hiding this comment

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

body length in bytes is != than base64 encoded char count.
we can probably read Content-Length here or just count bytes from requestData

} 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
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

responseBodyLength = utfString.count
}
}
Comment on lines +115 to +124
Copy link
Member

Choose a reason for hiding this comment

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

not sure if 100% the same but we can extract from start and reuse it here? if responseData and requestData are of the same type

}

// 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 {
Copy link
Member

Choose a reason for hiding this comment

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

getMonotonicTimeInMilliseconds already exists, can we reuse it? See URLSession extension

// 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
Loading