-
Notifications
You must be signed in to change notification settings - Fork 49
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 ?? [:] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to https://github.com/PostHog/posthog-ios/pull/260/files#r1851930833 |
||
|
||
// 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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. body length in bytes is != than base64 encoded char 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
responseBodyLength = utfString.count | ||
} | ||
} | ||
Comment on lines
+115
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure if 100% the same but we can extract from |
||
} | ||
|
||
// 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
// 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 |
There was a problem hiding this comment.
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?